mirror of
https://github.com/2dust/v2rayN.git
synced 2026-11-09 05:55:01 +03:00
Compare commits
160 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f7fb50b7b | |||
| f07e3dc415 | |||
| 0f71c46d3b | |||
| 968beb4432 | |||
| 1e167449ea | |||
| 73670c70d5 | |||
| 5ca37027fd | |||
| 54ecc045b5 | |||
| 92a9da8b6b | |||
| 6e48e507ea | |||
| 91d1cbe55a | |||
| f1ef8dfbe6 | |||
| 5d2e339e6d | |||
| 5211b9ce0e | |||
| c01224c37d | |||
| 052f6f0fec | |||
| 2010477f38 | |||
| 7c49b236a0 | |||
| b8458fdb7f | |||
| 92aa3ba6dd | |||
| 15ed51f942 | |||
| 8027b2cf72 | |||
| 66546700b0 | |||
| 9a029a3f7e | |||
| aa00b2c614 | |||
| 2239982a63 | |||
| 0d9e84556e | |||
| 9d1063f3f2 | |||
| 749a7312d7 | |||
| ff9fd7b988 | |||
| fe54a73d74 | |||
| 281f5ee3c7 | |||
| a74d10babb | |||
| 522bca492c | |||
| 0f1f82af4e | |||
| 3d0f45f6a8 | |||
| 19850f92be | |||
| 4c4449e74d | |||
| 59487dca75 | |||
| d4880dbe02 | |||
| 0856ffd7d8 | |||
| c436b998b4 | |||
| 7a52be4150 | |||
| 8c02b5f2f6 | |||
| 743eb323c2 | |||
| ebabf79017 | |||
| 756916978d | |||
| a2a8a77ff6 | |||
| 2bc79bc5a1 | |||
| 4c8b865d03 | |||
| 3d44ba8ec8 | |||
| aac4520f59 | |||
| b13ae2592a | |||
| bf2584c9a9 | |||
| e044b90c83 | |||
| 3dce503a08 | |||
| 70ab27e436 | |||
| 9593b289b9 | |||
| 5664270be7 | |||
| 926790b071 | |||
| e1c9633535 | |||
| 36184045bb | |||
| fcd313007a | |||
| 7e14cf9773 | |||
| ad3e24b6b5 | |||
| 4169c12a1f | |||
| 8b93d35766 | |||
| 78b0b2224b | |||
| e2f4292321 | |||
| 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 | |||
| 7d6a967c18 | |||
| 6c2edcb382 | |||
| 086c991464 | |||
| a9da04acc2 | |||
| e0c790eeb2 | |||
| d433aa3b04 | |||
| 91a7ed65d5 | |||
| 984e685ab3 | |||
| 29f99ceacc | |||
| 93d8174dbe | |||
| fa1e201c76 | |||
| e1cb99cd6e | |||
| 28139853a8 | |||
| 5d9718a0bb | |||
| 740729c847 | |||
| 13cd2ef80d | |||
| be3566df2f | |||
| 6e9ec6cbee | |||
| e6e95e00d7 | |||
| 2fbe81041b | |||
| 05901b5673 | |||
| bdd0ee1620 | |||
| 00d1001def | |||
| 21bafe52e0 | |||
| cef40d38ee | |||
| 4f5a6be12d | |||
| 7674d7e472 | |||
| 77c462ec7b | |||
| 526cd6834d | |||
| 7be23d7ebc | |||
| d952c180c9 | |||
| 80fa6575a3 | |||
| f05e44d7d9 | |||
| b10c16a94a | |||
| 143ada1358 | |||
| d8c7bd9d02 | |||
| 85666af36a | |||
| 7286c67a78 | |||
| 8b8e579bc8 | |||
| ed4dd7f6d9 | |||
| bb62c9eda5 | |||
| 3d50d9f82c | |||
| d05c3439f8 | |||
| cb5ae8c900 | |||
| ec6a7b3db2 | |||
| 99328a14d2 | |||
| 8ebbd7f4f8 | |||
| b8e8321603 | |||
| fe2e219b7a | |||
| df32b4d6cc | |||
| 9451b69246 | |||
| 4a59c7f4d6 | |||
| fed59a7313 | |||
| 093b8d4c34 | |||
| f03db020fa | |||
| f62eaab8c5 | |||
| 1bf3d14771 | |||
| 64aad8027a | |||
| 7870ae89cf |
@@ -0,0 +1,941 @@
|
|||||||
|
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:
|
||||||
|
macos:
|
||||||
|
name: macOS WebAPI and ServiceLib tests
|
||||||
|
runs-on: macos-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
env:
|
||||||
|
TUNIT_MAX_PARALLEL_TESTS: 1
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- uses: actions/setup-dotnet@v6
|
||||||
|
with:
|
||||||
|
dotnet-version: '10.x'
|
||||||
|
- name: Run ServiceLib.Tests
|
||||||
|
run: dotnet test --project v2rayN/ServiceLib.Tests/ServiceLib.Tests.csproj -c Release
|
||||||
|
- name: Run WebAPI.Tests
|
||||||
|
run: dotnet test --project v2rayN/v2rayN.WebAPI/Tests/v2rayN.WebAPI.Tests.csproj -c Release
|
||||||
|
|
||||||
|
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@v7
|
||||||
|
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@v7
|
||||||
|
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@v4
|
||||||
|
- 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@v8
|
||||||
|
with:
|
||||||
|
pattern: v2rayN.WebAPI-*
|
||||||
|
path: release-assets
|
||||||
|
merge-multiple: true
|
||||||
|
- name: Build and verify WebAPI-update.json
|
||||||
|
env:
|
||||||
|
RELEASE_TAG: ${{ needs.identity.outputs.tag }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
python3 v2rayN/v2rayN.WebAPI/Scripts/web-update-manifest.py write \
|
||||||
|
--version "$RELEASE_TAG" \
|
||||||
|
--repository "${{ github.repository }}" \
|
||||||
|
--commit "${{ github.sha }}" \
|
||||||
|
--build-date "${{ needs.identity.outputs.build_date }}" \
|
||||||
|
--dist release-assets \
|
||||||
|
--output release-assets/WebAPI-update.json
|
||||||
|
python3 v2rayN/v2rayN.WebAPI/Scripts/web-update-manifest.py verify \
|
||||||
|
--manifest release-assets/WebAPI-update.json \
|
||||||
|
--dist release-assets \
|
||||||
|
--repository "${{ github.repository }}"
|
||||||
|
- name: Smoke-test full API ZIP
|
||||||
|
run: bash v2rayN/v2rayN.WebAPI/Tests/Integration/test-full-zip-runtime.sh release-assets/v2rayN-linux-64-WebAPI.zip
|
||||||
|
- uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: WebAPI-release-packages
|
||||||
|
path: release-assets/*.zip
|
||||||
|
if-no-files-found: error
|
||||||
|
- uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: WebAPI-release-manifest
|
||||||
|
path: release-assets/WebAPI-update.json
|
||||||
|
if-no-files-found: error
|
||||||
@@ -20,6 +20,13 @@ jobs:
|
|||||||
secrets:
|
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:
|
||||||
|
|||||||
+113
-381
@@ -29,8 +29,24 @@ jobs:
|
|||||||
release_tag: ${{ inputs.release_tag }}
|
release_tag: ${{ inputs.release_tag }}
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
||||||
|
build-aot:
|
||||||
|
uses: ./.github/workflows/build.yml
|
||||||
|
with:
|
||||||
|
target: linux
|
||||||
|
isAot: true
|
||||||
|
|
||||||
|
release-aot-zip:
|
||||||
|
if: inputs.release_tag != ''
|
||||||
|
needs: build-aot
|
||||||
|
uses: ./.github/workflows/package-zip.yml
|
||||||
|
with:
|
||||||
|
target: linux
|
||||||
|
release_tag: ${{ inputs.release_tag }}
|
||||||
|
isAot: true
|
||||||
|
secrets: inherit
|
||||||
|
|
||||||
deb:
|
deb:
|
||||||
name: build and release deb x64 & arm64
|
name: build and release deb x64 & arm64 & riscv64 & ppc64le & loong64
|
||||||
if: (github.event_name == 'workflow_dispatch' && inputs.release_tag != '') || (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/'))
|
if: (github.event_name == 'workflow_dispatch' && inputs.release_tag != '') || (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/'))
|
||||||
runs-on: ubuntu-26.04
|
runs-on: ubuntu-26.04
|
||||||
container: debian:13
|
container: debian:13
|
||||||
@@ -54,8 +70,13 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
mkdir -p "$GITHUB_WORKSPACE/dist/deb"
|
mkdir -p "$GITHUB_WORKSPACE/dist/deb"
|
||||||
rsync -av "$HOME/debbuild/" "$GITHUB_WORKSPACE/dist/deb/" || true
|
rsync -av "$HOME/debbuild/" "$GITHUB_WORKSPACE/dist/deb/" || true
|
||||||
|
|
||||||
find "$GITHUB_WORKSPACE/dist/deb" -name "v2rayn_*_amd64.deb" -exec mv {} "$GITHUB_WORKSPACE/dist/deb/v2rayN-linux-64.deb" \; || true
|
find "$GITHUB_WORKSPACE/dist/deb" -name "v2rayn_*_amd64.deb" -exec mv {} "$GITHUB_WORKSPACE/dist/deb/v2rayN-linux-64.deb" \; || true
|
||||||
find "$GITHUB_WORKSPACE/dist/deb" -name "v2rayn_*_arm64.deb" -exec mv {} "$GITHUB_WORKSPACE/dist/deb/v2rayN-linux-arm64.deb" \; || true
|
find "$GITHUB_WORKSPACE/dist/deb" -name "v2rayn_*_arm64.deb" -exec mv {} "$GITHUB_WORKSPACE/dist/deb/v2rayN-linux-arm64.deb" \; || true
|
||||||
|
find "$GITHUB_WORKSPACE/dist/deb" -name "v2rayn_*_riscv64.deb" -exec mv {} "$GITHUB_WORKSPACE/dist/deb/v2rayN-linux-riscv64.deb" \; || true
|
||||||
|
find "$GITHUB_WORKSPACE/dist/deb" -name "v2rayn_*_ppc64el.deb" -exec mv {} "$GITHUB_WORKSPACE/dist/deb/v2rayN-linux-ppc64le.deb" \; || true
|
||||||
|
find "$GITHUB_WORKSPACE/dist/deb" -name "v2rayn_*_loong64.deb" -exec mv {} "$GITHUB_WORKSPACE/dist/deb/v2rayN-linux-loong64.deb" \; || true
|
||||||
|
|
||||||
echo "==== Dist tree ===="
|
echo "==== Dist tree ===="
|
||||||
ls -R "$GITHUB_WORKSPACE/dist/deb" || true
|
ls -R "$GITHUB_WORKSPACE/dist/deb" || true
|
||||||
- name: Upload DEB artifact
|
- name: Upload DEB artifact
|
||||||
@@ -63,7 +84,7 @@ jobs:
|
|||||||
with: { name: release-deb, path: "dist/deb/**/*.deb", if-no-files-found: error }
|
with: { name: release-deb, path: "dist/deb/**/*.deb", if-no-files-found: error }
|
||||||
|
|
||||||
rpm:
|
rpm:
|
||||||
name: build and release rpm x64 & arm64
|
name: build and release rpm x64 & arm64 & riscv64 & ppc64le & loong64
|
||||||
if: (github.event_name == 'workflow_dispatch' && inputs.release_tag != '') || (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/'))
|
if: (github.event_name == 'workflow_dispatch' && inputs.release_tag != '') || (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/'))
|
||||||
runs-on: ubuntu-26.04
|
runs-on: ubuntu-26.04
|
||||||
container: registry.access.redhat.com/ubi10/ubi
|
container: registry.access.redhat.com/ubi10/ubi
|
||||||
@@ -113,58 +134,23 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
mkdir -p "$GITHUB_WORKSPACE/dist/rpm"
|
mkdir -p "$GITHUB_WORKSPACE/dist/rpm"
|
||||||
rsync -av "$HOME/rpmbuild/RPMS/" "$GITHUB_WORKSPACE/dist/rpm/" || true
|
rsync -av "$HOME/rpmbuild/RPMS/" "$GITHUB_WORKSPACE/dist/rpm/" || true
|
||||||
|
|
||||||
find "$GITHUB_WORKSPACE/dist/rpm" -name "v2rayN-*-1*.x86_64.rpm" -exec mv {} "$GITHUB_WORKSPACE/dist/rpm/v2rayN-linux-rhel-64.rpm" \; || true
|
find "$GITHUB_WORKSPACE/dist/rpm" -name "v2rayN-*-1*.x86_64.rpm" -exec mv {} "$GITHUB_WORKSPACE/dist/rpm/v2rayN-linux-rhel-64.rpm" \; || true
|
||||||
find "$GITHUB_WORKSPACE/dist/rpm" -name "v2rayN-*-1*.aarch64.rpm" -exec mv {} "$GITHUB_WORKSPACE/dist/rpm/v2rayN-linux-rhel-arm64.rpm" \; || true
|
find "$GITHUB_WORKSPACE/dist/rpm" -name "v2rayN-*-1*.aarch64.rpm" -exec mv {} "$GITHUB_WORKSPACE/dist/rpm/v2rayN-linux-rhel-arm64.rpm" \; || true
|
||||||
|
find "$GITHUB_WORKSPACE/dist/rpm" -name "v2rayN-*-1*.riscv64.rpm" -exec mv {} "$GITHUB_WORKSPACE/dist/rpm/v2rayN-linux-rhel-riscv64.rpm" \; || true
|
||||||
|
find "$GITHUB_WORKSPACE/dist/rpm" -name "v2rayN-*-1*.ppc64le.rpm" -exec mv {} "$GITHUB_WORKSPACE/dist/rpm/v2rayN-linux-rhel-ppc64le.rpm" \; || true
|
||||||
|
find "$GITHUB_WORKSPACE/dist/rpm" -name "v2rayN-*-1*.loongarch64.rpm" -exec mv {} "$GITHUB_WORKSPACE/dist/rpm/v2rayN-linux-rhel-loong64.rpm" \; || true
|
||||||
|
|
||||||
echo "==== Dist tree ===="
|
echo "==== Dist tree ===="
|
||||||
ls -R "$GITHUB_WORKSPACE/dist/rpm" || true
|
ls -R "$GITHUB_WORKSPACE/dist/rpm" || true
|
||||||
- name: Upload RPM artifact
|
- name: Upload RPM artifact
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v7
|
||||||
with: { name: release-rpm, path: "dist/rpm/**/*.rpm", if-no-files-found: error }
|
with: { name: release-rpm, path: "dist/rpm/**/*.rpm", if-no-files-found: error }
|
||||||
|
|
||||||
rpm-riscv64:
|
deb-x86:
|
||||||
name: build and release rpm riscv64
|
name: build and release deb x86
|
||||||
if: (github.event_name == 'workflow_dispatch' && inputs.release_tag != '') || (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/'))
|
if: (github.event_name == 'workflow_dispatch' && inputs.release_tag != '') || (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/'))
|
||||||
runs-on: ubuntu-24.04-riscv
|
runs-on: ubuntu-26.04
|
||||||
container: rockylinux/rockylinux:10
|
|
||||||
env: { RELEASE_TAG: "${{ case(inputs.release_tag != '', inputs.release_tag, github.ref_name) }}" }
|
|
||||||
steps:
|
|
||||||
- name: Prepare tools (Red Hat)
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
dnf -y makecache
|
|
||||||
dnf -y install sudo git rpm-build rpmdevtools dnf-plugins-core rsync findutils tar gzip unzip which jq
|
|
||||||
- name: Checkout repo (for scripts)
|
|
||||||
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
|
|
||||||
git submodule update --init --recursive
|
|
||||||
- name: Ensure script permissions
|
|
||||||
run: chmod 755 package-rhel-riscv.sh
|
|
||||||
- name: Package RPM (RHEL-family)
|
|
||||||
run: ./package-rhel-riscv.sh "${RELEASE_TAG}"
|
|
||||||
- name: Collect RPMs into workspace
|
|
||||||
run: |
|
|
||||||
mkdir -p "$GITHUB_WORKSPACE/dist/rpm-riscv64"
|
|
||||||
rsync -av "$HOME/rpmbuild/RPMS/" "$GITHUB_WORKSPACE/dist/rpm-riscv64/" || true
|
|
||||||
find "$GITHUB_WORKSPACE/dist/rpm-riscv64" -name "*.riscv64.rpm" -exec mv {} "$GITHUB_WORKSPACE/dist/rpm-riscv64/v2rayN-linux-rhel-riscv64.rpm" \; || true
|
|
||||||
echo "==== Dist tree ===="
|
|
||||||
ls -R "$GITHUB_WORKSPACE/dist/rpm-riscv64" || true
|
|
||||||
- name: Upload RPM artifact
|
|
||||||
uses: actions/upload-artifact@v7
|
|
||||||
with: { name: release-rpm-riscv64, path: "dist/rpm-riscv64/**/*.rpm", if-no-files-found: error }
|
|
||||||
|
|
||||||
deb-riscv64:
|
|
||||||
name: build and release deb riscv64
|
|
||||||
if: (github.event_name == 'workflow_dispatch' && inputs.release_tag != '') || (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/'))
|
|
||||||
runs-on: ubuntu-24.04-riscv
|
|
||||||
container: debian:13
|
container: debian:13
|
||||||
env: { RELEASE_TAG: "${{ case(inputs.release_tag != '', inputs.release_tag, github.ref_name) }}" }
|
env: { RELEASE_TAG: "${{ case(inputs.release_tag != '', inputs.release_tag, github.ref_name) }}" }
|
||||||
steps:
|
steps:
|
||||||
@@ -174,372 +160,118 @@ jobs:
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
export DEBIAN_FRONTEND=noninteractive
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
apt-get update
|
apt-get update
|
||||||
apt-get install -y sudo git rsync findutils tar gzip unzip which curl jq wget file ca-certificates desktop-file-utils xdg-utils fakeroot dpkg-dev gcc make libc6-dev libgcc-s1 libstdc++6 zlib1g libatomic1
|
apt-get install -y sudo git rsync findutils tar gzip unzip which curl jq wget file ca-certificates desktop-file-utils xdg-utils fakeroot dpkg-dev libc6 libgcc-s1 libstdc++6 zlib1g libicu-dev libssl-dev
|
||||||
- name: Checkout repo (for scripts)
|
- name: Checkout repo (for scripts)
|
||||||
shell: bash
|
uses: actions/checkout@v7
|
||||||
env: { GITHUB_TOKEN: "${{ github.token }}" }
|
with: { submodules: 'recursive', fetch-depth: '0' }
|
||||||
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
|
|
||||||
git submodule update --init --recursive
|
|
||||||
- name: Ensure script permissions
|
- name: Ensure script permissions
|
||||||
run: chmod 755 package-debian-riscv.sh
|
run: chmod 755 package-debian-x86.sh
|
||||||
- name: Package DEB (Debian-family)
|
- name: Package DEB x86 (Debian-family)
|
||||||
run: ./package-debian-riscv.sh "${RELEASE_TAG}"
|
run: ./package-debian-x86.sh "${RELEASE_TAG}"
|
||||||
- name: Collect DEBs into workspace
|
- name: Collect x86 DEB into workspace
|
||||||
run: |
|
run: |
|
||||||
mkdir -p "$GITHUB_WORKSPACE/dist/deb-riscv64"
|
mkdir -p "$GITHUB_WORKSPACE/dist/deb-x86"
|
||||||
rsync -av "$HOME/debbuild/" "$GITHUB_WORKSPACE/dist/deb-riscv64/" || true
|
rsync -av "$HOME/debbuild/" "$GITHUB_WORKSPACE/dist/deb-x86/" || true
|
||||||
find "$GITHUB_WORKSPACE/dist/deb-riscv64" -name "*.deb" -exec mv {} "$GITHUB_WORKSPACE/dist/deb-riscv64/v2rayN-linux-riscv64.deb" \; || true
|
|
||||||
echo "==== Dist tree ===="
|
|
||||||
ls -R "$GITHUB_WORKSPACE/dist/deb-riscv64" || true
|
|
||||||
- name: Upload DEB artifact
|
|
||||||
uses: actions/upload-artifact@v7
|
|
||||||
with: { name: release-deb-riscv64, path: "dist/deb-riscv64/**/*.deb", if-no-files-found: error }
|
|
||||||
|
|
||||||
deb-loong64:
|
find "$GITHUB_WORKSPACE/dist/deb-x86" -name "v2rayn_*_i386.deb" \
|
||||||
name: build and release deb loong64
|
-exec mv {} "$GITHUB_WORKSPACE/dist/deb-x86/v2rayN-linux-86.deb" \; || true
|
||||||
|
|
||||||
|
echo "==== Dist tree ===="
|
||||||
|
ls -R "$GITHUB_WORKSPACE/dist/deb-x86" || true
|
||||||
|
- name: Upload x86 DEB artifact
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with: { name: release-deb-x86, path: "dist/deb-x86/**/*.deb", if-no-files-found: error }
|
||||||
|
|
||||||
|
rpm-x86:
|
||||||
|
name: build and release rpm x86
|
||||||
if: (github.event_name == 'workflow_dispatch' && inputs.release_tag != '') || (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/'))
|
if: (github.event_name == 'workflow_dispatch' && inputs.release_tag != '') || (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/'))
|
||||||
runs-on: ubuntu-26.04-arm
|
runs-on: ubuntu-26.04
|
||||||
env:
|
container: registry.access.redhat.com/ubi10/ubi
|
||||||
RELEASE_TAG: ${{ case(inputs.release_tag != '', inputs.release_tag, github.ref_name) }}
|
env: { RELEASE_TAG: "${{ case(inputs.release_tag != '', inputs.release_tag, github.ref_name) }}" }
|
||||||
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:
|
steps:
|
||||||
- name: Prepare host tools
|
- name: Prepare tools (Red Hat)
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
sudo apt-get update
|
. /etc/os-release
|
||||||
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
|
EL_MAJOR="${VERSION_ID%%.*}"
|
||||||
sudo ln -sf /usr/libexec/virtiofsd /usr/local/bin/virtiofsd
|
echo "EL_MAJOR=${EL_MAJOR}"
|
||||||
virtiofsd --version || virtiofsd --help | head
|
dnf -y makecache || true
|
||||||
- name: Host clone repo
|
command -v curl >/dev/null || dnf -y install curl ca-certificates
|
||||||
shell: bash
|
ARCH="$(uname -m)"
|
||||||
env: { GITHUB_TOKEN: "${{ github.token }}" }
|
case "$ARCH" in x86_64|aarch64) ;; *) echo "Unsupported arch: $ARCH"; exit 1 ;; esac
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
rm -rf repo
|
|
||||||
git clone --no-single-branch --tags "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" repo
|
|
||||||
- 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: Build loong64 DEB in VM through serial console
|
|
||||||
shell: bash
|
|
||||||
timeout-minutes: 180
|
|
||||||
env: { RELEASE_TAG: "${{ env.RELEASE_TAG }}" }
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
mkdir -p "$GITHUB_WORKSPACE/dist/deb-loong64"
|
|
||||||
|
|
||||||
export VIRTIOFSD_SOCKET="$RUNNER_TEMP/virtiofs-deb-loong64.sock"
|
install_epel_from_dir() {
|
||||||
rm -f "$VIRTIOFSD_SOCKET"
|
local base="$1" rpm
|
||||||
|
echo "Try: $base"
|
||||||
|
rpm="$({ curl -fsSL "$base/Packages/" 2>/dev/null; curl -fsSL "$base/Packages/e/" 2>/dev/null | sed 's|href="|href="e/|'; } |
|
||||||
|
sed -n 's/.*href="\([^"]*epel-release-[^"]*\.noarch\.rpm\)".*/\1/p' | sort -V | tail -n1)" || true
|
||||||
|
[[ -n "$rpm" ]] || return 1
|
||||||
|
dnf -y install "$base/Packages/$rpm"
|
||||||
|
}
|
||||||
|
|
||||||
sudo virtiofsd --socket-path="$VIRTIOFSD_SOCKET" --shared-dir="$GITHUB_WORKSPACE" --cache=auto &
|
FEDORA="https://dl.fedoraproject.org/pub/epel/epel-release-latest-${EL_MAJOR}.noarch.rpm"
|
||||||
|
ROCKY="https://dl.rockylinux.org/pub/rocky/${EL_MAJOR}/extras/${ARCH}/os"
|
||||||
|
ALMA="https://repo.almalinux.org/almalinux/${EL_MAJOR}/extras/${ARCH}/os"
|
||||||
|
|
||||||
VIRTIOFSD_PID=$!
|
echo "Try Fedora: $FEDORA"
|
||||||
trap 'sudo kill "$VIRTIOFSD_PID" 2>/dev/null || true; rm -f "$VIRTIOFSD_SOCKET"' EXIT
|
curl -fsSLI "$FEDORA" >/dev/null && dnf -y install "$FEDORA" ||
|
||||||
|
install_epel_from_dir "$ROCKY" ||
|
||||||
|
install_epel_from_dir "$ALMA" ||
|
||||||
|
{ echo "EPEL bootstrap failed (Fedora/Rocky/Alma)"; exit 1; }
|
||||||
|
|
||||||
for i in {1..100}; do
|
dnf -y install sudo git rpm-build rpmdevtools dnf-plugins-core rsync findutils tar gzip unzip which python3
|
||||||
[[ -S "$VIRTIOFSD_SOCKET" ]] && break
|
dnf repolist | grep -i epel || true
|
||||||
sleep 0.1
|
|
||||||
done
|
|
||||||
|
|
||||||
[[ -S "$VIRTIOFSD_SOCKET" ]] || { echo "virtiofsd socket not created"; exit 1; }
|
- name: Checkout repo (for scripts)
|
||||||
sudo chown "$USER:$(id -gn)" "$VIRTIOFSD_SOCKET" || true
|
uses: actions/checkout@v7
|
||||||
sudo chmod 660 "$VIRTIOFSD_SOCKET" || true
|
with: { submodules: 'recursive', fetch-depth: '0' }
|
||||||
expect <<'EOF'
|
|
||||||
log_user 1
|
- name: Ensure script permissions
|
||||||
set timeout -1
|
run: chmod 755 package-rhel-x86.sh
|
||||||
set release_tag $env(RELEASE_TAG)
|
|
||||||
set qemu_bin "$env(HOME)/qemu-install/bin/qemu-system-loongarch64"
|
- name: Package RPM x86 (RHEL-family)
|
||||||
set qemu_rom_dir "$env(HOME)/qemu-install/share/qemu"
|
run: ./package-rhel-x86.sh "${RELEASE_TAG}"
|
||||||
set virtiofs_socket $env(VIRTIOFSD_SOCKET)
|
|
||||||
set qcow2 $env(QCOW2_IMAGE)
|
- name: Collect x86 RPM into workspace
|
||||||
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 desktop-file-utils xdg-utils fakeroot dpkg-dev gcc make libc6-dev libgcc-s1 libstdc++6 zlib1g libatomic1 fuse3"
|
|
||||||
run_cmd "rm -rf /root/v2rayN-src"
|
|
||||||
run_cmd "rsync -a --info=progress2 /workspace/repo/ /root/v2rayN-src/"
|
|
||||||
run_cmd "cd /root/v2rayN-src && git config --global --add safe.directory /root/v2rayN-src"
|
|
||||||
run_cmd "cd /root/v2rayN-src && chmod 755 package-debian-loong.sh"
|
|
||||||
send -- "cd /root/v2rayN-src; cat >/tmp/run-loong-build.sh <<'SCRIPT'\nset +e\nset -o pipefail\nbash -x ./package-debian-loong.sh \"\$RELEASE_TAG\" 2>&1 | tee /tmp/build.log\nrc=\$?\nmkdir -p /workspace/dist/deb-loong64\ncp -av /root/debbuild/*.deb /workspace/dist/deb-loong64/ 2>/dev/null || true\necho __BUILD_DONE__\$rc\nSCRIPT\nRELEASE_TAG=\"$release_tag\" bash /tmp/run-loong-build.sh\r"
|
|
||||||
expect {
|
|
||||||
-re "__BUILD_DONE__0" { send -- "poweroff\r" }
|
|
||||||
default { exit 1 }
|
|
||||||
}
|
|
||||||
EOF
|
|
||||||
- name: Collect DEBs
|
|
||||||
shell: bash
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
mkdir -p "$GITHUB_WORKSPACE/dist/rpm-x86"
|
||||||
mkdir -p "$GITHUB_WORKSPACE/dist/deb-loong64"
|
rsync -av "$HOME/rpmbuild/RPMS/" "$GITHUB_WORKSPACE/dist/rpm-x86/" || true
|
||||||
find "$GITHUB_WORKSPACE/dist/deb-loong64" -name "*.deb" -exec mv {} "$GITHUB_WORKSPACE/dist/deb-loong64/v2rayN-linux-loong64.deb" \; || true
|
|
||||||
|
find "$GITHUB_WORKSPACE/dist/rpm-x86" -name "v2rayN-*-1*.i686.rpm" \
|
||||||
|
-exec mv {} "$GITHUB_WORKSPACE/dist/rpm-x86/v2rayN-linux-rhel-86.rpm" \; || true
|
||||||
|
|
||||||
echo "==== Dist tree ===="
|
echo "==== Dist tree ===="
|
||||||
ls -R "$GITHUB_WORKSPACE/dist/deb-loong64"
|
ls -R "$GITHUB_WORKSPACE/dist/rpm-x86" || true
|
||||||
- name: Upload DEB artifact
|
|
||||||
|
- name: Upload x86 RPM artifact
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v7
|
||||||
with: { name: release-deb-loong64, path: "dist/deb-loong64/**/*.deb", if-no-files-found: error }
|
with: { name: release-rpm-x86, path: "dist/rpm-x86/**/*.rpm", if-no-files-found: error }
|
||||||
|
|
||||||
rpm-loong64:
|
|
||||||
name: build and release rpm loong64
|
|
||||||
if: (github.event_name == 'workflow_dispatch' && inputs.release_tag != '') || (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/'))
|
|
||||||
runs-on: ubuntu-26.04-arm
|
|
||||||
env:
|
|
||||||
RELEASE_TAG: ${{ case(inputs.release_tag != '', inputs.release_tag, github.ref_name) }}
|
|
||||||
QCOW2_URL: https://github.com/xujiegb/fedora-loong64-qcow2/releases/download/43/fedora43-loong64.qcow2
|
|
||||||
EFI_CODE_URL: https://github.com/xujiegb/fedora-loong64-qcow2/releases/download/43/edk2-loongarch64-code.fd
|
|
||||||
EFI_VARS_URL: https://github.com/xujiegb/fedora-loong64-qcow2/releases/download/43/edk2-loongarch64-vars.fd
|
|
||||||
QCOW2_IMAGE: fedora43-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 clone --no-single-branch --tags "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" repo
|
|
||||||
- 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: Build loong64 RPM in VM through serial console
|
|
||||||
shell: bash
|
|
||||||
timeout-minutes: 180
|
|
||||||
env: { RELEASE_TAG: "${{ env.RELEASE_TAG }}" }
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
mkdir -p "$GITHUB_WORKSPACE/dist/rpm-loong64"
|
|
||||||
|
|
||||||
export VIRTIOFSD_SOCKET="$RUNNER_TEMP/virtiofs-rpm-loong64.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 -1
|
|
||||||
set release_tag $env(RELEASE_TAG)
|
|
||||||
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:|fedora 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 "dnf -y makecache || dnf -y makecache || dnf -y makecache"
|
|
||||||
run_cmd "dnf -y install sudo git rpm-build rpmdevtools dnf-plugins-core rsync findutils tar gzip unzip which curl jq file ca-certificates fuse3 --nogpgcheck"
|
|
||||||
run_cmd "rm -rf /root/v2rayN-src"
|
|
||||||
run_cmd "rsync -a --info=progress2 /workspace/repo/ /root/v2rayN-src/"
|
|
||||||
run_cmd "cd /root/v2rayN-src && git config --global --add safe.directory /root/v2rayN-src"
|
|
||||||
run_cmd "cd /root/v2rayN-src && chmod 755 package-rhel-loong.sh"
|
|
||||||
send -- "cd /root/v2rayN-src; cat >/tmp/run-loong-rpm-build.sh <<'SCRIPT'\nset +e\nset -o pipefail\nbash -x ./package-rhel-loong.sh \"\$RELEASE_TAG\" 2>&1 | tee /tmp/build.log\nrc=\$?\nmkdir -p /workspace/dist/rpm-loong64\nfind /root/rpmbuild/RPMS -name '*.rpm' -exec cp -av {} /workspace/dist/rpm-loong64/ \\; 2>/dev/null || true\necho __BUILD_DONE__\$rc\nSCRIPT\nRELEASE_TAG=\"$release_tag\" bash /tmp/run-loong-rpm-build.sh\r"
|
|
||||||
expect {
|
|
||||||
-re "__BUILD_DONE__0" { send -- "poweroff\r" }
|
|
||||||
default { exit 1 }
|
|
||||||
}
|
|
||||||
EOF
|
|
||||||
- name: Collect RPMs
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
mkdir -p "$GITHUB_WORKSPACE/dist/rpm-loong64"
|
|
||||||
find "$GITHUB_WORKSPACE/dist/rpm-loong64" -name "*.loongarch64.rpm" -exec mv {} "$GITHUB_WORKSPACE/dist/rpm-loong64/v2rayN-linux-rhel-loong64.rpm" \; || true
|
|
||||||
echo "==== Dist tree ===="
|
|
||||||
ls -R "$GITHUB_WORKSPACE/dist/rpm-loong64"
|
|
||||||
- name: Upload RPM artifact
|
|
||||||
uses: actions/upload-artifact@v7
|
|
||||||
with: { name: release-rpm-loong64, path: "dist/rpm-loong64/**/*.rpm", if-no-files-found: error }
|
|
||||||
|
|
||||||
release-deb:
|
release-deb:
|
||||||
name: sign and upload deb x64 arm64
|
name: sign and upload deb x64 arm64 riscv64 ppc64le loong64
|
||||||
needs: deb
|
needs: deb
|
||||||
uses: ./.github/workflows/upload-sign.yml
|
uses: ./.github/workflows/upload-sign.yml
|
||||||
with: { release_tag: "${{ case(inputs.release_tag != '', inputs.release_tag, github.ref_name) }}", artifact_name: release-deb, file_pattern: "*.deb" }
|
with: { release_tag: "${{ inputs.release_tag || github.ref_name }}", artifact_name: release-deb, file_pattern: "*.deb" }
|
||||||
secrets: { GPG_PRIVATE_KEY: "${{ secrets.GPG_PRIVATE_KEY }}" }
|
secrets: { GPG_PRIVATE_KEY: "${{ secrets.GPG_PRIVATE_KEY }}" }
|
||||||
|
|
||||||
release-rpm:
|
release-rpm:
|
||||||
name: sign and upload rpm x64 arm64
|
name: sign and upload rpm x64 arm64 riscv64 ppc64le loong64
|
||||||
needs: rpm
|
needs: rpm
|
||||||
uses: ./.github/workflows/upload-sign.yml
|
uses: ./.github/workflows/upload-sign.yml
|
||||||
with: { release_tag: "${{ case(inputs.release_tag != '', inputs.release_tag, github.ref_name) }}", artifact_name: release-rpm, file_pattern: "*.rpm" }
|
with: { release_tag: "${{ inputs.release_tag || github.ref_name }}", artifact_name: release-rpm, file_pattern: "*.rpm" }
|
||||||
secrets: { GPG_PRIVATE_KEY: "${{ secrets.GPG_PRIVATE_KEY }}" }
|
secrets: { GPG_PRIVATE_KEY: "${{ secrets.GPG_PRIVATE_KEY }}" }
|
||||||
|
|
||||||
release-rpm-riscv64:
|
release-deb-x86:
|
||||||
name: sign and upload rpm riscv64
|
name: sign and upload deb x86
|
||||||
needs: rpm-riscv64
|
needs: deb-x86
|
||||||
uses: ./.github/workflows/upload-sign.yml
|
uses: ./.github/workflows/upload-sign.yml
|
||||||
with: { release_tag: "${{ case(inputs.release_tag != '', inputs.release_tag, github.ref_name) }}", artifact_name: release-rpm-riscv64, file_pattern: "*.rpm" }
|
with: { release_tag: "${{ inputs.release_tag || github.ref_name }}", artifact_name: release-deb-x86, file_pattern: "*.deb" }
|
||||||
secrets: { GPG_PRIVATE_KEY: "${{ secrets.GPG_PRIVATE_KEY }}" }
|
secrets: { GPG_PRIVATE_KEY: "${{ secrets.GPG_PRIVATE_KEY }}" }
|
||||||
|
|
||||||
release-deb-riscv64:
|
release-rpm-x86:
|
||||||
name: sign and upload deb riscv64
|
name: sign and upload rpm x86
|
||||||
needs: deb-riscv64
|
needs: rpm-x86
|
||||||
uses: ./.github/workflows/upload-sign.yml
|
uses: ./.github/workflows/upload-sign.yml
|
||||||
with: { release_tag: "${{ case(inputs.release_tag != '', inputs.release_tag, github.ref_name) }}", artifact_name: release-deb-riscv64, file_pattern: "*.deb" }
|
with: { release_tag: "${{ inputs.release_tag || github.ref_name }}", artifact_name: release-rpm-x86, file_pattern: "*.rpm" }
|
||||||
secrets: { GPG_PRIVATE_KEY: "${{ secrets.GPG_PRIVATE_KEY }}" }
|
|
||||||
|
|
||||||
release-deb-loong64:
|
|
||||||
name: sign and upload deb loong64
|
|
||||||
needs: deb-loong64
|
|
||||||
uses: ./.github/workflows/upload-sign.yml
|
|
||||||
with: { release_tag: "${{ case(inputs.release_tag != '', inputs.release_tag, github.ref_name) }}", artifact_name: release-deb-loong64, file_pattern: "*.deb" }
|
|
||||||
secrets: { GPG_PRIVATE_KEY: "${{ secrets.GPG_PRIVATE_KEY }}" }
|
|
||||||
|
|
||||||
release-rpm-loong64:
|
|
||||||
name: sign and upload rpm loong64
|
|
||||||
needs: rpm-loong64
|
|
||||||
uses: ./.github/workflows/upload-sign.yml
|
|
||||||
with: { release_tag: "${{ case(inputs.release_tag != '', inputs.release_tag, github.ref_name) }}", artifact_name: release-rpm-loong64, file_pattern: "*.rpm" }
|
|
||||||
secrets: { GPG_PRIVATE_KEY: "${{ secrets.GPG_PRIVATE_KEY }}" }
|
secrets: { GPG_PRIVATE_KEY: "${{ secrets.GPG_PRIVATE_KEY }}" }
|
||||||
|
|||||||
@@ -29,6 +29,22 @@ jobs:
|
|||||||
release_tag: ${{ inputs.release_tag }}
|
release_tag: ${{ inputs.release_tag }}
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
||||||
|
build-aot:
|
||||||
|
uses: ./.github/workflows/build.yml
|
||||||
|
with:
|
||||||
|
target: macos
|
||||||
|
isAot: true
|
||||||
|
|
||||||
|
release-aot-zip:
|
||||||
|
if: inputs.release_tag != ''
|
||||||
|
needs: build-aot
|
||||||
|
uses: ./.github/workflows/package-zip.yml
|
||||||
|
with:
|
||||||
|
target: macos
|
||||||
|
release_tag: ${{ inputs.release_tag }}
|
||||||
|
isAot: true
|
||||||
|
secrets: inherit
|
||||||
|
|
||||||
dmg:
|
dmg:
|
||||||
name: package and release macOS dmg
|
name: package and release macOS dmg
|
||||||
if: inputs.release_tag != ''
|
if: inputs.release_tag != ''
|
||||||
|
|||||||
@@ -28,3 +28,19 @@ jobs:
|
|||||||
target: windows-desktop
|
target: windows-desktop
|
||||||
release_tag: ${{ inputs.release_tag }}
|
release_tag: ${{ inputs.release_tag }}
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
||||||
|
build-aot:
|
||||||
|
uses: ./.github/workflows/build.yml
|
||||||
|
with:
|
||||||
|
target: windows
|
||||||
|
isAot: true
|
||||||
|
|
||||||
|
release-aot-zip:
|
||||||
|
if: inputs.release_tag != ''
|
||||||
|
needs: build-aot
|
||||||
|
uses: ./.github/workflows/package-zip.yml
|
||||||
|
with:
|
||||||
|
target: windows-desktop
|
||||||
|
release_tag: ${{ inputs.release_tag }}
|
||||||
|
isAot: true
|
||||||
|
secrets: inherit
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ jobs:
|
|||||||
$xrayTag = "v$xrayVer"
|
$xrayTag = "v$xrayVer"
|
||||||
$singTag = "v$singVer"
|
$singTag = "v$singVer"
|
||||||
|
|
||||||
$xrayUrl = "https://github.com/autorepobot/Xray/releases/download/$xrayTag/Xray-windows-32.zip"
|
$xrayUrl = "https://github.com/XTLS/Xray-core/releases/download/$xrayTag/Xray-windows-32.zip"
|
||||||
$singUrl = "https://github.com/SagerNet/sing-box/releases/download/$singTag/sing-box-$singVer-windows-386.zip"
|
$singUrl = "https://github.com/SagerNet/sing-box/releases/download/$singTag/sing-box-$singVer-windows-386.zip"
|
||||||
|
|
||||||
Write-Host "Bundled Xray version: $xrayVer"
|
Write-Host "Bundled Xray version: $xrayVer"
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ on:
|
|||||||
required: false
|
required: false
|
||||||
type: string
|
type: string
|
||||||
default: './v2rayN.Desktop/v2rayN.Desktop.csproj'
|
default: './v2rayN.Desktop/v2rayN.Desktop.csproj'
|
||||||
|
isAot:
|
||||||
|
required: false
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
@@ -27,7 +31,6 @@ jobs:
|
|||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
env:
|
env:
|
||||||
Output: "${{ github.workspace }}/${{ matrix.arch }}"
|
|
||||||
RID: |-
|
RID: |-
|
||||||
${{
|
${{
|
||||||
case(
|
case(
|
||||||
@@ -41,6 +44,7 @@ jobs:
|
|||||||
${{
|
${{
|
||||||
case(
|
case(
|
||||||
inputs.target == 'windows', '-p:EnableWindowsTargeting=true',
|
inputs.target == 'windows', '-p:EnableWindowsTargeting=true',
|
||||||
|
inputs.target == 'linux', (matrix.arch == 'arm64' && '-p:LinkerFlavor=lld -p:ObjCopyName=llvm-objcopy' || ''),
|
||||||
''
|
''
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
@@ -71,25 +75,65 @@ jobs:
|
|||||||
- name: Setup .NET
|
- name: Setup .NET
|
||||||
uses: actions/setup-dotnet@v6
|
uses: actions/setup-dotnet@v6
|
||||||
with:
|
with:
|
||||||
dotnet-version: 10.0.1xx
|
dotnet-version: 10.0.x
|
||||||
|
|
||||||
- name: Build v2rayN
|
- name: Install dependencies
|
||||||
shell: bash
|
shell: bash
|
||||||
working-directory: ./v2rayN
|
if: inputs.target == 'linux' && inputs.isAot
|
||||||
run: dotnet publish $Project -c Release -r $RID -p:SelfContained=true $ExtOpt -o $Output
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y clang zlib1g-dev build-essential
|
||||||
|
|
||||||
|
- name: Install dependencies cross-arch
|
||||||
|
shell: bash
|
||||||
|
if: inputs.target == 'linux' && inputs.isAot && matrix.arch == 'arm64'
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y lld llvm binutils-aarch64-linux-gnu gcc-aarch64-linux-gnu g++-aarch64-linux-gnu libc6-dev-arm64-cross
|
||||||
|
|
||||||
|
- name: Set AOT Environment Variable
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
if [ "${{ inputs.isAot }}" = "true" ]; then
|
||||||
|
echo "DOTNET_AOT_ARG=-p:IsAot=true" >> $GITHUB_ENV
|
||||||
|
else
|
||||||
|
echo "DOTNET_AOT_ARG=-p:IsAot=false" >> $GITHUB_ENV
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Set OUTPUT Environment Variable
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
RAW_PATH="${{ github.workspace }}/${{ matrix.arch }}${{ inputs.isAot && '-aot' || '' }}"
|
||||||
|
echo "OUTPUT=${RAW_PATH//\\//}" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Build AmazTool
|
- name: Build AmazTool
|
||||||
shell: bash
|
shell: bash
|
||||||
working-directory: ./v2rayN
|
working-directory: ./v2rayN
|
||||||
run: dotnet publish ./AmazTool/AmazTool.csproj -c Release -r $RID -p:SelfContained=true -p:PublishTrimmed=true $ExtOpt -o $Output
|
run: dotnet publish ./AmazTool/AmazTool.csproj -c Release -r $RID -p:SelfContained=true -p:PublishTrimmed=true $ExtOpt ${{ env.DOTNET_AOT_ARG }} -o ${{ env.OUTPUT }}
|
||||||
|
|
||||||
|
- name: Build v2rayN WPF
|
||||||
|
shell: bash
|
||||||
|
if: inputs.project == './v2rayN/v2rayN.csproj'
|
||||||
|
working-directory: ./v2rayN
|
||||||
|
run: dotnet publish $Project -c Release -r $RID -p:SelfContained=true $ExtOpt -o ${{ env.OUTPUT }}
|
||||||
|
|
||||||
|
- name: Build v2rayN Avalonia
|
||||||
|
shell: bash
|
||||||
|
if: inputs.project == './v2rayN.Desktop/v2rayN.Desktop.csproj'
|
||||||
|
working-directory: ./v2rayN
|
||||||
|
run: dotnet publish $Project -c Release -r $RID -p:SelfContained=true $ExtOpt ${{ env.DOTNET_AOT_ARG }} -o ${{ env.OUTPUT }}
|
||||||
|
|
||||||
- name: Remove Debug Symbols
|
- name: Remove Debug Symbols
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
find ${{ matrix.arch }} -type f -name '*.pdb' -delete
|
find ${{ env.OUTPUT }} -type f -name '*.pdb' -delete
|
||||||
|
find ${{ env.OUTPUT }} -type f -name '*.debug' -delete
|
||||||
|
find ${{ env.OUTPUT }} -type f -name '*.dbg' -delete
|
||||||
|
find ${{ env.OUTPUT }} -type f -name '*.sym' -delete
|
||||||
|
find ${{ env.OUTPUT }} -name '*.dSYM' -exec rm -rf {} +
|
||||||
|
|
||||||
- name: Upload build artifacts
|
- name: Upload build artifacts
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: ${{ matrix.arch }}
|
name: ${{ matrix.arch }}${{ inputs.isAot && '-aot' || '' }}
|
||||||
path: ${{ matrix.arch }}
|
path: ${{ env.OUTPUT }}
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ on:
|
|||||||
target: # windows linux macos windows-desktop
|
target: # windows linux macos windows-desktop
|
||||||
required: true
|
required: true
|
||||||
type: string
|
type: string
|
||||||
|
isAot:
|
||||||
|
required: false
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
secrets:
|
secrets:
|
||||||
GPG_PRIVATE_KEY:
|
GPG_PRIVATE_KEY:
|
||||||
required: true
|
required: true
|
||||||
@@ -43,27 +47,62 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
|
- name: Set package variables
|
||||||
|
id: package-vars
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
INPUT_TARGET: ${{ inputs.target }}
|
||||||
|
INPUT_IS_AOT: ${{ inputs.isAot }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
package_dir="v2rayN-${Target}-${Arch}"
|
||||||
|
package_file="${package_dir}.zip"
|
||||||
|
build_artifact_name="${{ matrix.arch }}"
|
||||||
|
rename_suffix=""
|
||||||
|
if [[ "$INPUT_IS_AOT" == "true" ]]; then
|
||||||
|
build_artifact_name="${build_artifact_name}-aot"
|
||||||
|
rename_suffix="-aot"
|
||||||
|
fi
|
||||||
|
if [[ "$INPUT_TARGET" == "windows-desktop" ]]; then
|
||||||
|
rename_suffix="-desktop${rename_suffix}"
|
||||||
|
fi
|
||||||
|
final_package_file="${package_dir}${rename_suffix}.zip"
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "build_artifact_name=${build_artifact_name}"
|
||||||
|
echo "package_dir=${package_dir}"
|
||||||
|
echo "package_file=${package_file}"
|
||||||
|
echo "final_package_file=${final_package_file}"
|
||||||
|
echo "rename_suffix=${rename_suffix}"
|
||||||
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Restore build artifacts
|
- name: Restore build artifacts
|
||||||
uses: actions/download-artifact@v8
|
uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
name: ${{ matrix.arch }}
|
name: ${{ steps.package-vars.outputs.build_artifact_name }}
|
||||||
path: v2rayN-${{ env.Target }}-${{ env.Arch }}
|
path: ${{ steps.package-vars.outputs.package_dir }}
|
||||||
|
|
||||||
- name: Get v2rayN-core-bin
|
- name: Get v2rayN-core-bin
|
||||||
run: wget -nv -O v2rayN-$Target-$Arch.zip "https://github.com/2dust/v2rayN-core-bin/raw/refs/heads/master/v2rayN-$Target-$Arch.zip"
|
run: |
|
||||||
|
wget -nv -O "${{ steps.package-vars.outputs.package_file }}" "https://github.com/2dust/v2rayN-core-bin/raw/refs/heads/master/v2rayN-$Target-$Arch.zip"
|
||||||
|
|
||||||
- name: Package zip archive
|
- name: Package zip archive
|
||||||
run: 7z a -tZip v2rayN-$Target-$Arch.zip v2rayN-$Target-$Arch -mx1
|
shell: bash
|
||||||
|
run: |
|
||||||
|
7z a -tZip "${{ steps.package-vars.outputs.package_file }}" "${{ steps.package-vars.outputs.package_dir }}" -mx1
|
||||||
|
|
||||||
- name: Rename windows-desktop
|
- name: Rename package archive
|
||||||
if: inputs.target == 'windows-desktop'
|
if: steps.package-vars.outputs.rename_suffix != ''
|
||||||
run: mv "v2rayN-$Target-$Arch.zip" "v2rayN-$Target-$Arch-desktop.zip"
|
shell: bash
|
||||||
|
run: |
|
||||||
|
mv "${{ steps.package-vars.outputs.package_file }}" \
|
||||||
|
"${{ steps.package-vars.outputs.package_dir }}${{ steps.package-vars.outputs.rename_suffix }}.zip"
|
||||||
|
|
||||||
- name: Upload zip artifact
|
- name: Upload zip artifact
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: release-${{ inputs.target }}-${{ matrix.arch }}
|
name: release-${{ inputs.target }}-${{ matrix.arch }}${{ inputs.isAot && '-aot' || '' }}
|
||||||
path: v2rayN*.zip
|
path: ${{ steps.package-vars.outputs.final_package_file }}
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
release:
|
release:
|
||||||
@@ -75,7 +114,7 @@ jobs:
|
|||||||
uses: ./.github/workflows/upload-sign.yml
|
uses: ./.github/workflows/upload-sign.yml
|
||||||
with:
|
with:
|
||||||
release_tag: ${{ inputs.release_tag }}
|
release_tag: ${{ inputs.release_tag }}
|
||||||
artifact_name: release-${{ inputs.target }}-${{ matrix.arch }}
|
artifact_name: release-${{ inputs.target }}-${{ matrix.arch }}${{ inputs.isAot && '-aot' || '' }}
|
||||||
file_pattern: '*.zip'
|
file_pattern: '*.zip'
|
||||||
secrets:
|
secrets:
|
||||||
GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
|
GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
|
||||||
|
|||||||
@@ -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 }}" }
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
name: Update Certificate
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 0 * * *'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
update-cert:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
|
- name: Setup .NET
|
||||||
|
uses: actions/setup-dotnet@v6
|
||||||
|
with:
|
||||||
|
dotnet-version: 10.0.x
|
||||||
|
|
||||||
|
- name: Update Certificate
|
||||||
|
run: |
|
||||||
|
dotnet run .github/scripts/UpdateCert.cs
|
||||||
|
|
||||||
|
- name: Create Pull Request
|
||||||
|
uses: peter-evans/create-pull-request@v8
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
commit-message: "Update certificate"
|
||||||
|
title: "Update certificate"
|
||||||
|
body: "This pull request updates the certificate."
|
||||||
|
branch: github-actions/update-certificate
|
||||||
|
delete-branch: true
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -44,11 +44,11 @@ Read the Wiki for usage guides and configuration details.
|
|||||||
|
|
||||||
## Supported Platforms / 支持平台
|
## Supported Platforms / 支持平台
|
||||||
|
|
||||||
| Platform / 平台 | x64 | x86 | arm64 | riscv64 | loong64 |
|
| Platform / 平台 | x64 | x86 | arm64 | riscv64 | loong64 | ppc64le |
|
||||||
| --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- | --- |
|
||||||
| Windows | ✅ | ✅ | ✅ | - | - |
|
| Windows | ✅ | ✅ | ✅ | - | - | - |
|
||||||
| Linux | ✅ | - | ✅ | ✅ | ✅ |
|
| Linux | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| macOS | ✅ | - | ✅ | - | - |
|
| macOS | ✅ | - | ✅ | - | - | - |
|
||||||
|
|
||||||
Minimum OS requirements: [Release files introduction](https://github.com/2dust/v2rayN/wiki/Release-files-introduction) / 最低系统要求:[发布文件介绍](https://github.com/2dust/v2rayN/wiki/Release-files-introduction)
|
Minimum OS requirements: [Release files introduction](https://github.com/2dust/v2rayN/wiki/Release-files-introduction) / 最低系统要求:[发布文件介绍](https://github.com/2dust/v2rayN/wiki/Release-files-introduction)
|
||||||
|
|
||||||
|
|||||||
@@ -1,729 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
VERSION_ARG=""
|
|
||||||
WITH_CORE="both"
|
|
||||||
FORCE_NETCORE=0
|
|
||||||
BUILD_FROM=""
|
|
||||||
XRAY_VER="${XRAY_VER:-}"
|
|
||||||
SING_VER="${SING_VER:-}"
|
|
||||||
|
|
||||||
MIN_KERNEL="6.12"
|
|
||||||
PKGROOT="v2rayN-publish"
|
|
||||||
PROJECT_HINT="v2rayN.Desktop/v2rayN.Desktop.csproj"
|
|
||||||
OUTPUT_DIR="${HOME}/debbuild"
|
|
||||||
DOTNET_TFM="net10.0"
|
|
||||||
DOTNET_LOONGARCH_VERSION="10.0.111"
|
|
||||||
DOTNET_LOONGARCH_TAG="v10.0.111-loongarch64"
|
|
||||||
DOTNET_LOONGARCH_BASE="https://github.com/loongson/dotnet/releases/download"
|
|
||||||
DOTNET_LOONGARCH_FILE="dotnet-sdk-${DOTNET_LOONGARCH_VERSION}-linux-loongarch64.tar.gz"
|
|
||||||
DOTNET_SDK_URL="${DOTNET_LOONGARCH_BASE}/${DOTNET_LOONGARCH_TAG}/${DOTNET_LOONGARCH_FILE}"
|
|
||||||
|
|
||||||
OS_ID=""
|
|
||||||
OS_NAME=""
|
|
||||||
OS_VERSION_ID=""
|
|
||||||
HOST_ARCH=""
|
|
||||||
SCRIPT_DIR=""
|
|
||||||
PROJECT=""
|
|
||||||
VERSION=""
|
|
||||||
|
|
||||||
declare -a BUILT_DEBS=()
|
|
||||||
|
|
||||||
die() {
|
|
||||||
echo "$*" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
parse_args() {
|
|
||||||
local first_arg="${1:-}"
|
|
||||||
|
|
||||||
if [[ -n "$first_arg" && "$first_arg" != --* ]]; then
|
|
||||||
VERSION_ARG="$first_arg"
|
|
||||||
shift || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
|
||||||
case "$1" in
|
|
||||||
--with-core) WITH_CORE="${2:-both}"; shift 2 ;;
|
|
||||||
--xray-ver) XRAY_VER="${2:-}"; shift 2 ;;
|
|
||||||
--singbox-ver) SING_VER="${2:-}"; shift 2 ;;
|
|
||||||
--netcore) FORCE_NETCORE=1; shift ;;
|
|
||||||
--buildfrom) BUILD_FROM="${2:-}"; shift 2 ;;
|
|
||||||
*)
|
|
||||||
[[ -n "${VERSION_ARG:-}" ]] || VERSION_ARG="$1"
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
if [[ -n "${VERSION_ARG:-}" && -n "${BUILD_FROM:-}" ]]; then
|
|
||||||
die "You cannot specify both an explicit version and --buildfrom at the same time.
|
|
||||||
Provide either a version (e.g. 7.14.0) OR --buildfrom 1|2|3."
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
detect_environment() {
|
|
||||||
local current_kernel=""
|
|
||||||
local lowest=""
|
|
||||||
|
|
||||||
. /etc/os-release
|
|
||||||
|
|
||||||
OS_ID="${ID:-}"
|
|
||||||
OS_NAME="${NAME:-$OS_ID}"
|
|
||||||
OS_VERSION_ID="${VERSION_ID:-}"
|
|
||||||
HOST_ARCH="$(uname -m)"
|
|
||||||
|
|
||||||
case "$OS_ID" in
|
|
||||||
debian)
|
|
||||||
echo "Detected supported system: ${OS_NAME:-$OS_ID} ${OS_VERSION_ID:-}"
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
die "Unsupported system: ${OS_NAME:-unknown} (${OS_ID:-unknown}).
|
|
||||||
This script only supports: Debian."
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
case "$HOST_ARCH" in
|
|
||||||
loongarch64) ;;
|
|
||||||
*) die "Only supports loongarch64" ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
current_kernel="$(uname -r)"
|
|
||||||
lowest="$(printf '%s\n%s\n' "$MIN_KERNEL" "$current_kernel" | sort -V | head -n1)"
|
|
||||||
|
|
||||||
[[ "$lowest" == "$MIN_KERNEL" ]] || die "Kernel $current_kernel is below $MIN_KERNEL"
|
|
||||||
echo "[OK] Kernel $current_kernel verified."
|
|
||||||
}
|
|
||||||
|
|
||||||
install_dependencies() {
|
|
||||||
local install_ok=0
|
|
||||||
local tmp_dotnet=""
|
|
||||||
|
|
||||||
mkdir -p "$OUTPUT_DIR"
|
|
||||||
|
|
||||||
if command -v apt-get >/dev/null 2>&1; then
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get -y install \
|
|
||||||
curl unzip tar jq rsync ca-certificates git dpkg-dev fakeroot file \
|
|
||||||
desktop-file-utils xdg-utils wget gcc make pkg-config \
|
|
||||||
libicu-dev libssl-dev libfontconfig1 libfreetype6 zlib1g
|
|
||||||
|
|
||||||
mkdir -p "$HOME/.dotnet"
|
|
||||||
tmp_dotnet="$(mktemp -d)"
|
|
||||||
curl -fL "$DOTNET_SDK_URL" -o "$tmp_dotnet/$DOTNET_LOONGARCH_FILE"
|
|
||||||
tar -C "$HOME/.dotnet" -xzf "$tmp_dotnet/$DOTNET_LOONGARCH_FILE"
|
|
||||||
rm -rf "$tmp_dotnet"
|
|
||||||
|
|
||||||
export PATH="$HOME/.dotnet:$PATH"
|
|
||||||
export DOTNET_ROOT="$HOME/.dotnet"
|
|
||||||
|
|
||||||
dotnet --info >/dev/null 2>&1 && install_ok=1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "$install_ok" -ne 1 ]]; then
|
|
||||||
echo "Could not auto-install dependencies for '$OS_ID'. Make sure these are available:"
|
|
||||||
echo "dotnet-loongarch SDK, curl, unzip, tar, rsync, git, gcc, make, dpkg-deb, fakeroot, libicu-dev, libssl-dev"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
prepare_workspace() {
|
|
||||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
cd "$SCRIPT_DIR"
|
|
||||||
|
|
||||||
if [[ -f .gitmodules ]]; then
|
|
||||||
git submodule sync --recursive || true
|
|
||||||
git submodule update --init --recursive || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
PROJECT="$PROJECT_HINT"
|
|
||||||
[[ -f "$PROJECT" ]] || PROJECT="$(find . -maxdepth 3 -name 'v2rayN.Desktop.csproj' | head -n1 || true)"
|
|
||||||
[[ -f "$PROJECT" ]] || die "v2rayN.Desktop.csproj not found"
|
|
||||||
}
|
|
||||||
|
|
||||||
choose_channel() {
|
|
||||||
local ch="latest"
|
|
||||||
local sel=""
|
|
||||||
|
|
||||||
if [[ -n "${BUILD_FROM:-}" ]]; then
|
|
||||||
case "$BUILD_FROM" in
|
|
||||||
1) echo "latest"; return 0 ;;
|
|
||||||
2) echo "prerelease"; return 0 ;;
|
|
||||||
3) echo "keep"; return 0 ;;
|
|
||||||
*) die "[ERROR] Invalid --buildfrom value: ${BUILD_FROM}. Use 1|2|3." ;;
|
|
||||||
esac
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -t 0 ]]; then
|
|
||||||
echo "[?] Choose v2rayN release channel:" >&2
|
|
||||||
echo " 1) Latest (stable) [default]" >&2
|
|
||||||
echo " 2) Pre-release (preview)" >&2
|
|
||||||
echo " 3) Keep current (do nothing)" >&2
|
|
||||||
printf "Enter 1, 2 or 3 [default 1]: " >&2
|
|
||||||
|
|
||||||
if read -r sel </dev/tty; then
|
|
||||||
case "${sel:-}" in
|
|
||||||
2) ch="prerelease" ;;
|
|
||||||
3) ch="keep" ;;
|
|
||||||
esac
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "$ch"
|
|
||||||
}
|
|
||||||
|
|
||||||
get_latest_tag_latest() {
|
|
||||||
curl -fsSL "https://api.github.com/repos/2dust/v2rayN/releases/latest" \
|
|
||||||
| jq -re '.tag_name' \
|
|
||||||
| sed 's/^v//'
|
|
||||||
}
|
|
||||||
|
|
||||||
get_latest_tag_prerelease() {
|
|
||||||
curl -fsSL "https://api.github.com/repos/2dust/v2rayN/releases?per_page=20" \
|
|
||||||
| jq -re 'first(.[] | select(.prerelease == true) | .tag_name)' \
|
|
||||||
| sed 's/^v//'
|
|
||||||
}
|
|
||||||
|
|
||||||
sync_submodules() {
|
|
||||||
if [[ -f .gitmodules ]]; then
|
|
||||||
git submodule sync --recursive || true
|
|
||||||
git submodule update --init --recursive || true
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
git_try_checkout() {
|
|
||||||
local want="$1"
|
|
||||||
local ref=""
|
|
||||||
|
|
||||||
if git rev-parse --git-dir >/dev/null 2>&1; then
|
|
||||||
git fetch --tags --force --prune --depth=1 || true
|
|
||||||
git rev-parse "refs/tags/${want}" >/dev/null 2>&1 && ref="$want"
|
|
||||||
|
|
||||||
if [[ -n "$ref" ]]; then
|
|
||||||
echo "[OK] Found ref '${ref}', checking out..."
|
|
||||||
git checkout -f "$ref"
|
|
||||||
sync_submodules
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
apply_channel_or_keep() {
|
|
||||||
local ch="$1"
|
|
||||||
local tag=""
|
|
||||||
|
|
||||||
if [[ "$ch" == "keep" ]]; then
|
|
||||||
echo "[*] Keep current repository state (no checkout)."
|
|
||||||
VERSION="$(git describe --tags --abbrev=0 2>/dev/null || echo '0.0.0+git')"
|
|
||||||
VERSION="${VERSION#v}"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "[*] Resolving ${ch} tag from GitHub releases..."
|
|
||||||
|
|
||||||
case "$ch" in
|
|
||||||
latest) tag="$(get_latest_tag_latest || true)" ;;
|
|
||||||
prerelease) tag="$(get_latest_tag_prerelease || true)" ;;
|
|
||||||
*) die "Failed to resolve latest tag for channel '${ch}'." ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
[[ -n "$tag" ]] || die "Failed to resolve latest tag for channel '${ch}'."
|
|
||||||
|
|
||||||
echo "[*] Latest tag for '${ch}': ${tag}"
|
|
||||||
git_try_checkout "$tag" || die "Failed to checkout '${tag}'."
|
|
||||||
VERSION="${tag#v}"
|
|
||||||
}
|
|
||||||
|
|
||||||
resolve_version() {
|
|
||||||
if git rev-parse --git-dir >/dev/null 2>&1; then
|
|
||||||
if [[ -n "${VERSION_ARG:-}" ]]; then
|
|
||||||
local clean_ver="${VERSION_ARG#v}"
|
|
||||||
|
|
||||||
if git_try_checkout "$clean_ver"; then
|
|
||||||
VERSION="$clean_ver"
|
|
||||||
else
|
|
||||||
echo "[WARN] Tag '${VERSION_ARG}' not found."
|
|
||||||
apply_channel_or_keep "$(choose_channel)"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
apply_channel_or_keep "$(choose_channel)"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo "Current directory is not a git repo; proceeding on current tree."
|
|
||||||
VERSION="${VERSION_ARG:-0.0.0}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
VERSION="${VERSION#v}"
|
|
||||||
echo "[*] GUI version resolved as: ${VERSION}"
|
|
||||||
}
|
|
||||||
|
|
||||||
xray_url_for_rid() {
|
|
||||||
local rid="$1"
|
|
||||||
local ver="$2"
|
|
||||||
|
|
||||||
case "$rid" in
|
|
||||||
linux-loongarch64) echo "https://github.com/XTLS/Xray-core/releases/download/v${ver}/Xray-linux-loong64.zip" ;;
|
|
||||||
*) return 1 ;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
singbox_url_for_rid() {
|
|
||||||
local rid="$1"
|
|
||||||
local ver="$2"
|
|
||||||
|
|
||||||
case "$rid" in
|
|
||||||
linux-loongarch64) echo "https://github.com/SagerNet/sing-box/releases/download/v${ver}/sing-box-${ver}-linux-loong64.tar.gz" ;;
|
|
||||||
*) return 1 ;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
bundle_url_for_rid() {
|
|
||||||
local rid="$1"
|
|
||||||
|
|
||||||
case "$rid" in
|
|
||||||
linux-loongarch64) echo "https://raw.githubusercontent.com/2dust/v2rayN-core-bin/refs/heads/master/v2rayN-linux-loong64.zip" ;;
|
|
||||||
*) return 1 ;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
download_xray() {
|
|
||||||
local outdir="$1"
|
|
||||||
local rid="$2"
|
|
||||||
local ver="${XRAY_VER:-}"
|
|
||||||
local url=""
|
|
||||||
local tmp=""
|
|
||||||
|
|
||||||
mkdir -p "$outdir"
|
|
||||||
|
|
||||||
if [[ -z "$ver" ]]; then
|
|
||||||
ver="$(curl -fsSL https://api.github.com/repos/XTLS/Xray-core/releases/latest \
|
|
||||||
| grep -Eo '"tag_name":\s*"v[^"]+"' \
|
|
||||||
| sed -E 's/.*"v([^"]+)".*/\1/' \
|
|
||||||
| head -n1)" || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
[[ -n "$ver" ]] || { echo "[xray] Failed to get version"; return 1; }
|
|
||||||
url="$(xray_url_for_rid "$rid" "$ver")" || { echo "[xray] Unsupported RID: $rid"; return 1; }
|
|
||||||
|
|
||||||
echo "[+] Download xray: $url"
|
|
||||||
|
|
||||||
tmp="$(mktemp -d)"
|
|
||||||
curl -fL "$url" -o "$tmp/xray.zip" || { rm -rf "$tmp"; return 1; }
|
|
||||||
unzip -q "$tmp/xray.zip" -d "$tmp" || { rm -rf "$tmp"; return 1; }
|
|
||||||
install -m 755 "$tmp/xray" "$outdir/xray" || { rm -rf "$tmp"; return 1; }
|
|
||||||
rm -rf "$tmp"
|
|
||||||
}
|
|
||||||
|
|
||||||
download_singbox() {
|
|
||||||
local outdir="$1"
|
|
||||||
local rid="$2"
|
|
||||||
local ver="${SING_VER:-}"
|
|
||||||
local url=""
|
|
||||||
local tmp=""
|
|
||||||
local bin=""
|
|
||||||
local cronet=""
|
|
||||||
|
|
||||||
mkdir -p "$outdir"
|
|
||||||
|
|
||||||
if [[ -z "$ver" ]]; then
|
|
||||||
ver="$(curl -fsSL https://api.github.com/repos/SagerNet/sing-box/releases/latest \
|
|
||||||
| grep -Eo '"tag_name":\s*"v[^"]+"' \
|
|
||||||
| sed -E 's/.*"v([^"]+)".*/\1/' \
|
|
||||||
| head -n1)" || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
[[ -n "$ver" ]] || { echo "[sing-box] Failed to get version"; return 1; }
|
|
||||||
url="$(singbox_url_for_rid "$rid" "$ver")" || { echo "[sing-box] Unsupported RID: $rid"; return 1; }
|
|
||||||
|
|
||||||
echo "[+] Download sing-box: $url"
|
|
||||||
|
|
||||||
tmp="$(mktemp -d)"
|
|
||||||
curl -fL "$url" -o "$tmp/singbox.tar.gz" || { rm -rf "$tmp"; return 1; }
|
|
||||||
tar -C "$tmp" -xzf "$tmp/singbox.tar.gz" || { rm -rf "$tmp"; return 1; }
|
|
||||||
|
|
||||||
bin="$(find "$tmp" -type f -name 'sing-box' | head -n1 || true)"
|
|
||||||
[[ -n "$bin" ]] || { echo "[!] sing-box unpack failed"; rm -rf "$tmp"; return 1; }
|
|
||||||
|
|
||||||
install -m 755 "$bin" "$outdir/sing-box" || { rm -rf "$tmp"; return 1; }
|
|
||||||
|
|
||||||
cronet="$(find "$tmp" -type f -name 'libcronet*.so*' | head -n1 || true)"
|
|
||||||
[[ -n "$cronet" ]] && install -m 644 "$cronet" "$outdir/libcronet.so" || true
|
|
||||||
|
|
||||||
rm -rf "$tmp"
|
|
||||||
}
|
|
||||||
|
|
||||||
unify_geo_layout() {
|
|
||||||
local outroot="$1"
|
|
||||||
local n
|
|
||||||
local names=(
|
|
||||||
geosite.dat
|
|
||||||
geoip.dat
|
|
||||||
geoip-only-cn-private.dat
|
|
||||||
Country.mmdb
|
|
||||||
geoip.metadb
|
|
||||||
)
|
|
||||||
|
|
||||||
mkdir -p "$outroot/bin"
|
|
||||||
|
|
||||||
for n in "${names[@]}"; do
|
|
||||||
if [[ -f "$outroot/bin/xray/$n" ]]; then
|
|
||||||
mv -f "$outroot/bin/xray/$n" "$outroot/bin/$n"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
}
|
|
||||||
|
|
||||||
download_geo_assets() {
|
|
||||||
local outroot="$1"
|
|
||||||
local bin_dir="$outroot/bin"
|
|
||||||
local srss_dir="$bin_dir/srss"
|
|
||||||
local f=""
|
|
||||||
|
|
||||||
mkdir -p "$bin_dir" "$srss_dir"
|
|
||||||
|
|
||||||
echo "[+] Download Xray Geo to ${bin_dir}"
|
|
||||||
curl -fsSL -o "$bin_dir/geosite.dat" "https://github.com/Loyalsoldier/V2ray-rules-dat/releases/latest/download/geosite.dat"
|
|
||||||
curl -fsSL -o "$bin_dir/geoip.dat" "https://github.com/Loyalsoldier/V2ray-rules-dat/releases/latest/download/geoip.dat"
|
|
||||||
curl -fsSL -o "$bin_dir/geoip-only-cn-private.dat" "https://raw.githubusercontent.com/Loyalsoldier/geoip/release/geoip-only-cn-private.dat"
|
|
||||||
curl -fsSL -o "$bin_dir/Country.mmdb" "https://raw.githubusercontent.com/Loyalsoldier/geoip/release/Country.mmdb"
|
|
||||||
|
|
||||||
echo "[+] Download sing-box rule DB & rule-sets"
|
|
||||||
curl -fsSL -o "$bin_dir/geoip.metadb" "https://github.com/MetaCubeX/meta-rules-dat/releases/latest/download/geoip.metadb"
|
|
||||||
|
|
||||||
for f in geoip-private.srs geoip-cn.srs geoip-facebook.srs geoip-fastly.srs geoip-google.srs geoip-netflix.srs geoip-telegram.srs geoip-twitter.srs; do
|
|
||||||
curl -fsSL -o "$srss_dir/$f" "https://raw.githubusercontent.com/2dust/sing-box-rules/refs/heads/rule-set-geoip/$f"
|
|
||||||
done
|
|
||||||
|
|
||||||
for f in geosite-cn.srs geosite-gfw.srs geosite-google.srs geosite-greatfire.srs geosite-geolocation-cn.srs geosite-category-ads-all.srs geosite-private.srs; do
|
|
||||||
curl -fsSL -o "$srss_dir/$f" "https://raw.githubusercontent.com/2dust/sing-box-rules/refs/heads/rule-set-geosite/$f"
|
|
||||||
done
|
|
||||||
|
|
||||||
unify_geo_layout "$outroot"
|
|
||||||
}
|
|
||||||
|
|
||||||
populate_assets_zip_mode() {
|
|
||||||
local outroot="$1"
|
|
||||||
local rid="$2"
|
|
||||||
local url=""
|
|
||||||
local tmp=""
|
|
||||||
local nested_dir=""
|
|
||||||
|
|
||||||
url="$(bundle_url_for_rid "$rid")" || { echo "[!] Bundle unsupported RID: $rid"; return 1; }
|
|
||||||
|
|
||||||
echo "[+] Try v2rayN bundle archive: $url"
|
|
||||||
|
|
||||||
tmp="$(mktemp -d)"
|
|
||||||
curl -fL "$url" -o "$tmp/v2rayn.zip" || { echo "[!] Bundle download failed"; rm -rf "$tmp"; return 1; }
|
|
||||||
unzip -q "$tmp/v2rayn.zip" -d "$tmp" || { echo "[!] Bundle unzip failed"; rm -rf "$tmp"; return 1; }
|
|
||||||
|
|
||||||
if [[ -d "$tmp/bin" ]]; then
|
|
||||||
mkdir -p "$outroot/bin"
|
|
||||||
rsync -a "$tmp/bin/" "$outroot/bin/"
|
|
||||||
else
|
|
||||||
rsync -a "$tmp/" "$outroot/"
|
|
||||||
fi
|
|
||||||
|
|
||||||
rm -f "$outroot/v2rayn.zip" 2>/dev/null || true
|
|
||||||
find "$outroot" -type d -name "mihomo" -prune -exec rm -rf {} + 2>/dev/null || true
|
|
||||||
|
|
||||||
nested_dir="$(find "$outroot" -maxdepth 1 -type d -name 'v2rayN-linux-*' | head -n1 || true)"
|
|
||||||
if [[ -n "$nested_dir" && -d "$nested_dir/bin" ]]; then
|
|
||||||
mkdir -p "$outroot/bin"
|
|
||||||
rsync -a "$nested_dir/bin/" "$outroot/bin/"
|
|
||||||
rm -rf "$nested_dir"
|
|
||||||
fi
|
|
||||||
|
|
||||||
unify_geo_layout "$outroot"
|
|
||||||
rm -rf "$tmp"
|
|
||||||
|
|
||||||
echo "[+] Bundle extracted to $outroot"
|
|
||||||
}
|
|
||||||
|
|
||||||
populate_assets_netcore_mode() {
|
|
||||||
local outroot="$1"
|
|
||||||
local rid="$2"
|
|
||||||
|
|
||||||
mkdir -p "$outroot/bin/xray" "$outroot/bin/sing_box"
|
|
||||||
|
|
||||||
if [[ "$WITH_CORE" == "xray" || "$WITH_CORE" == "both" ]]; then
|
|
||||||
download_xray "$outroot/bin/xray" "$rid" || echo "[!] xray download failed (skipped)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "$WITH_CORE" == "sing-box" || "$WITH_CORE" == "both" ]]; then
|
|
||||||
download_singbox "$outroot/bin/sing_box" "$rid" || echo "[!] sing-box download failed (skipped)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
download_geo_assets "$outroot" || echo "[!] Geo rules download failed (skipped)"
|
|
||||||
}
|
|
||||||
|
|
||||||
stage_runtime_assets() {
|
|
||||||
local outroot="$1"
|
|
||||||
local rid="$2"
|
|
||||||
|
|
||||||
mkdir -p "$outroot/bin/xray" "$outroot/bin/sing_box"
|
|
||||||
|
|
||||||
if [[ "$FORCE_NETCORE" -eq 0 ]]; then
|
|
||||||
if populate_assets_zip_mode "$outroot" "$rid"; then
|
|
||||||
echo "[*] Using v2rayN bundle archive."
|
|
||||||
else
|
|
||||||
echo "[*] Bundle failed, fallback to separate core + rules."
|
|
||||||
populate_assets_netcore_mode "$outroot" "$rid"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo "[*] --netcore specified: use separate core + rules."
|
|
||||||
populate_assets_netcore_mode "$outroot" "$rid"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
describe_target() {
|
|
||||||
local short="$1"
|
|
||||||
|
|
||||||
case "$short" in
|
|
||||||
loongarch64) printf '%s\n%s\n' "linux-loongarch64" "loong64" ;;
|
|
||||||
*) echo "Unknown arch '$short' (use loongarch64)" >&2; return 1 ;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
publish_binary() {
|
|
||||||
local rid="$1"
|
|
||||||
|
|
||||||
dotnet clean "$PROJECT" -c Release
|
|
||||||
rm -rf "$(dirname "$PROJECT")/bin/Release/net10.0" || true
|
|
||||||
dotnet restore "$PROJECT"
|
|
||||||
dotnet publish "$PROJECT" -c Release -r "$rid" -p:PublishSingleFile=false -p:SelfContained=true
|
|
||||||
}
|
|
||||||
|
|
||||||
write_launcher_file() {
|
|
||||||
local stage="$1"
|
|
||||||
|
|
||||||
install -m 755 /dev/stdin "$stage/usr/bin/v2rayn" <<'EOF'
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
DIR="/opt/v2rayN"
|
|
||||||
cd "$DIR"
|
|
||||||
|
|
||||||
if [[ -x "$DIR/v2rayN" ]]; then
|
|
||||||
exec "$DIR/v2rayN" "$@"
|
|
||||||
fi
|
|
||||||
|
|
||||||
for dll in v2rayN.Desktop.dll v2rayN.dll; do
|
|
||||||
if [[ -f "$DIR/$dll" ]]; then
|
|
||||||
exec /usr/bin/dotnet "$DIR/$dll" "$@"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "v2rayN launcher: no executable found in $DIR" >&2
|
|
||||||
ls -l "$DIR" >&2 || true
|
|
||||||
exit 1
|
|
||||||
EOF
|
|
||||||
}
|
|
||||||
|
|
||||||
write_desktop_file() {
|
|
||||||
local stage="$1"
|
|
||||||
|
|
||||||
install -m 644 /dev/stdin "$stage/usr/share/applications/v2rayn.desktop" <<'EOF'
|
|
||||||
[Desktop Entry]
|
|
||||||
Type=Application
|
|
||||||
Name=v2rayN
|
|
||||||
Comment=v2rayN for Debian GNU Linux
|
|
||||||
Exec=v2rayn
|
|
||||||
Icon=v2rayn
|
|
||||||
Terminal=false
|
|
||||||
Categories=Network;
|
|
||||||
EOF
|
|
||||||
}
|
|
||||||
|
|
||||||
write_maintainer_scripts() {
|
|
||||||
local debian_dir="$1"
|
|
||||||
|
|
||||||
install -m 755 /dev/stdin "$debian_dir/postinst" <<'EOF'
|
|
||||||
#!/bin/sh
|
|
||||||
set -e
|
|
||||||
update-desktop-database /usr/share/applications >/dev/null 2>&1 || true
|
|
||||||
if command -v gtk-update-icon-cache >/dev/null 2>&1; then
|
|
||||||
gtk-update-icon-cache -f /usr/share/icons/hicolor >/dev/null 2>&1 || true
|
|
||||||
fi
|
|
||||||
exit 0
|
|
||||||
EOF
|
|
||||||
|
|
||||||
install -m 755 /dev/stdin "$debian_dir/postrm" <<'EOF'
|
|
||||||
#!/bin/sh
|
|
||||||
set -e
|
|
||||||
update-desktop-database /usr/share/applications >/dev/null 2>&1 || true
|
|
||||||
if command -v gtk-update-icon-cache >/dev/null 2>&1; then
|
|
||||||
gtk-update-icon-cache -f /usr/share/icons/hicolor >/dev/null 2>&1 || true
|
|
||||||
fi
|
|
||||||
exit 0
|
|
||||||
EOF
|
|
||||||
}
|
|
||||||
|
|
||||||
package_binary() {
|
|
||||||
local short="$1"
|
|
||||||
local rid="$2"
|
|
||||||
local deb_arch="$3"
|
|
||||||
local pubdir=""
|
|
||||||
local workdir=""
|
|
||||||
local stage=""
|
|
||||||
local debian_dir=""
|
|
||||||
local project_dir=""
|
|
||||||
local icon_candidate=""
|
|
||||||
local shlibs_depends=""
|
|
||||||
local extra_depends=""
|
|
||||||
local final_depends=""
|
|
||||||
local multiarch=""
|
|
||||||
local sys_libdir=""
|
|
||||||
local sys_usrlibdir=""
|
|
||||||
local deb_out=""
|
|
||||||
|
|
||||||
pubdir="$(dirname "$PROJECT")/bin/Release/net10.0/${rid}/publish"
|
|
||||||
[[ -d "$pubdir" ]] || { echo "Publish directory not found: $pubdir"; return 1; }
|
|
||||||
|
|
||||||
workdir="$(mktemp -d)"
|
|
||||||
trap '[[ -n "${workdir:-}" ]] && rm -rf "$workdir"' RETURN
|
|
||||||
|
|
||||||
stage="$workdir/${PKGROOT}_${VERSION}_${deb_arch}"
|
|
||||||
debian_dir="$stage/DEBIAN"
|
|
||||||
|
|
||||||
mkdir -p "$stage/opt/v2rayN" "$stage/usr/bin" "$stage/usr/share/applications" "$stage/usr/share/icons/hicolor/256x256/apps" "$debian_dir"
|
|
||||||
cp -a "$pubdir/." "$stage/opt/v2rayN/"
|
|
||||||
|
|
||||||
project_dir="$(cd "$(dirname "$PROJECT")" && pwd)"
|
|
||||||
icon_candidate="$project_dir/v2rayN.png"
|
|
||||||
[[ -f "$icon_candidate" ]] && cp "$icon_candidate" "$stage/usr/share/icons/hicolor/256x256/apps/v2rayn.png" || true
|
|
||||||
|
|
||||||
stage_runtime_assets "$stage/opt/v2rayN" "$rid"
|
|
||||||
write_launcher_file "$stage"
|
|
||||||
write_desktop_file "$stage"
|
|
||||||
write_maintainer_scripts "$debian_dir"
|
|
||||||
|
|
||||||
extra_depends="libc6 (>= 2.39), fontconfig (>= 2.15.0), desktop-file-utils (>= 0.26), xdg-utils (>= 1.1.3), coreutils (>= 9.4), bash (>= 5.2.21), libfreetype6 (>= 2.13)"
|
|
||||||
|
|
||||||
mkdir -p "$workdir/debian"
|
|
||||||
cat > "$workdir/debian/control" <<EOF
|
|
||||||
Source: v2rayn
|
|
||||||
Section: net
|
|
||||||
Priority: optional
|
|
||||||
Maintainer: 2dust <noreply@github.com>
|
|
||||||
Standards-Version: 4.7.0
|
|
||||||
|
|
||||||
Package: v2rayn
|
|
||||||
Architecture: ${deb_arch}
|
|
||||||
Description: v2rayN
|
|
||||||
EOF
|
|
||||||
|
|
||||||
multiarch="$(dpkg-architecture -a"$deb_arch" -qDEB_HOST_MULTIARCH)"
|
|
||||||
sys_libdir="/lib/$multiarch"
|
|
||||||
sys_usrlibdir="/usr/lib/$multiarch"
|
|
||||||
|
|
||||||
: > "$debian_dir/substvars"
|
|
||||||
|
|
||||||
mapfile -t ELF_FILES < <(
|
|
||||||
find "$stage/opt/v2rayN" -type f \( -name "*.so*" -o -perm -111 \) ! -name 'libcoreclrtraceptprovider.so'
|
|
||||||
)
|
|
||||||
|
|
||||||
if [[ "${#ELF_FILES[@]}" -gt 0 ]]; then
|
|
||||||
(
|
|
||||||
cd "$workdir"
|
|
||||||
dpkg-shlibdeps \
|
|
||||||
-l"$stage/opt/v2rayN" \
|
|
||||||
-l"$sys_libdir" \
|
|
||||||
-l"$sys_usrlibdir" \
|
|
||||||
-T"$debian_dir/substvars" \
|
|
||||||
"${ELF_FILES[@]}"
|
|
||||||
) >/dev/null 2>&1 || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
shlibs_depends="$(sed -n 's/^shlibs:Depends=//p' "$debian_dir/substvars" | head -n1 || true)"
|
|
||||||
|
|
||||||
if [[ -n "$shlibs_depends" ]]; then
|
|
||||||
shlibs_depends="$(echo "$shlibs_depends" \
|
|
||||||
| sed -E 's/ *\([^)]*\)//g' \
|
|
||||||
| sed -E 's/ *, */, /g' \
|
|
||||||
| sed -E 's/^, *//; s/, *$//')"
|
|
||||||
final_depends="${shlibs_depends}, ${extra_depends}"
|
|
||||||
else
|
|
||||||
final_depends="${extra_depends}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
cat > "$debian_dir/control" <<EOF
|
|
||||||
Package: v2rayn
|
|
||||||
Version: ${VERSION}
|
|
||||||
Architecture: ${deb_arch}
|
|
||||||
Maintainer: 2dust <noreply@github.com>
|
|
||||||
Homepage: https://github.com/2dust/v2rayN
|
|
||||||
Section: net
|
|
||||||
Priority: optional
|
|
||||||
Depends: ${final_depends}
|
|
||||||
Description: v2rayN (Avalonia) GUI client for Linux
|
|
||||||
Support vless / vmess / Trojan / http / socks / Anytls / Hysteria2 /
|
|
||||||
Shadowsocks / tuic / WireGuard.
|
|
||||||
EOF
|
|
||||||
|
|
||||||
find "$stage/opt/v2rayN" -type d -exec chmod 0755 {} +
|
|
||||||
find "$stage/opt/v2rayN" -type f -exec chmod 0644 {} +
|
|
||||||
[[ -f "$stage/opt/v2rayN/v2rayN" ]] && chmod 0755 "$stage/opt/v2rayN/v2rayN" || true
|
|
||||||
|
|
||||||
deb_out="$OUTPUT_DIR/v2rayn_${VERSION}_${deb_arch}.deb"
|
|
||||||
dpkg-deb --root-owner-group --build "$stage" "$deb_out"
|
|
||||||
|
|
||||||
echo "Build done for $short. DEB at:"
|
|
||||||
echo " $deb_out"
|
|
||||||
BUILT_DEBS+=("$deb_out")
|
|
||||||
}
|
|
||||||
|
|
||||||
select_targets() {
|
|
||||||
printf '%s\n' loongarch64
|
|
||||||
}
|
|
||||||
|
|
||||||
build_one_target() {
|
|
||||||
local short="$1"
|
|
||||||
local meta=()
|
|
||||||
local rid=""
|
|
||||||
local deb_arch=""
|
|
||||||
|
|
||||||
mapfile -t meta < <(describe_target "$short") || return 1
|
|
||||||
rid="${meta[0]}"
|
|
||||||
deb_arch="${meta[1]}"
|
|
||||||
|
|
||||||
echo "[*] Building for target: $short (RID=$rid, DEB arch=$deb_arch)"
|
|
||||||
publish_binary "$rid"
|
|
||||||
package_binary "$short" "$rid" "$deb_arch"
|
|
||||||
}
|
|
||||||
|
|
||||||
print_summary() {
|
|
||||||
local pkg=""
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "================ Build Summary ================="
|
|
||||||
if [[ "${#BUILT_DEBS[@]}" -gt 0 ]]; then
|
|
||||||
echo "Output directory: $OUTPUT_DIR"
|
|
||||||
for pkg in "${BUILT_DEBS[@]}"; do
|
|
||||||
echo "$pkg"
|
|
||||||
done
|
|
||||||
else
|
|
||||||
echo "No DEBs detected in summary (check build logs above)."
|
|
||||||
fi
|
|
||||||
echo "==============================================="
|
|
||||||
}
|
|
||||||
|
|
||||||
main() {
|
|
||||||
local targets=()
|
|
||||||
local arch=""
|
|
||||||
|
|
||||||
parse_args "$@"
|
|
||||||
detect_environment
|
|
||||||
install_dependencies
|
|
||||||
prepare_workspace
|
|
||||||
resolve_version
|
|
||||||
|
|
||||||
mapfile -t targets < <(select_targets)
|
|
||||||
|
|
||||||
for arch in "${targets[@]}"; do
|
|
||||||
build_one_target "$arch"
|
|
||||||
done
|
|
||||||
|
|
||||||
print_summary
|
|
||||||
}
|
|
||||||
|
|
||||||
main "$@"
|
|
||||||
@@ -8,14 +8,21 @@ BUILD_FROM=""
|
|||||||
XRAY_VER="${XRAY_VER:-}"
|
XRAY_VER="${XRAY_VER:-}"
|
||||||
SING_VER="${SING_VER:-}"
|
SING_VER="${SING_VER:-}"
|
||||||
|
|
||||||
MIN_KERNEL="5.10"
|
MIN_KERNEL="6.12"
|
||||||
PKGROOT="v2rayN-publish"
|
PKGROOT="v2rayN-publish"
|
||||||
PROJECT_HINT="v2rayN.Desktop/v2rayN.Desktop.csproj"
|
PROJECT_HINT="v2rayN.Desktop/v2rayN.Desktop.csproj"
|
||||||
OUTPUT_DIR="${HOME}/debbuild"
|
OUTPUT_DIR="${HOME}/debbuild"
|
||||||
DOTNET_RISCV_VERSION="10.0.111"
|
|
||||||
DOTNET_RISCV_BASE="https://github.com/xujiegb/dotnet-riscv/releases/download"
|
NUGET_CACHE_DIR=""
|
||||||
DOTNET_RISCV_FILE="dotnet-sdk-${DOTNET_RISCV_VERSION}-linux-riscv64.tar.gz"
|
DOTNET_SDK_DIR=""
|
||||||
DOTNET_SDK_URL="${DOTNET_RISCV_BASE}/${DOTNET_RISCV_VERSION}/${DOTNET_RISCV_FILE}"
|
|
||||||
|
DOTNET_NUPKG_BASE="${DOTNET_NUPKG_BASE:-https://github.com/autorepobot/dotnet-nupkg-prebuild/releases/download}"
|
||||||
|
SKIASHARP_BASE="${SKIASHARP_BASE:-https://github.com/autorepobot/SkiaSharp-ppc64le/releases/download}"
|
||||||
|
SkiaSharp_VERSION="${SkiaSharp_VERSION:-3.119.4}"
|
||||||
|
HarfBuzzSharp_VERSION="${HarfBuzzSharp_VERSION:-8.3.1.5}"
|
||||||
|
|
||||||
|
DOTNET_SDK_VERSION="${DOTNET_SDK_VERSION:-10.0.114}"
|
||||||
|
DOTNET_X86_VERSION="${DOTNET_X86_VERSION:-10.0.114}"
|
||||||
|
|
||||||
OS_ID=""
|
OS_ID=""
|
||||||
OS_NAME=""
|
OS_NAME=""
|
||||||
@@ -24,6 +31,10 @@ HOST_ARCH=""
|
|||||||
SCRIPT_DIR=""
|
SCRIPT_DIR=""
|
||||||
PROJECT=""
|
PROJECT=""
|
||||||
VERSION=""
|
VERSION=""
|
||||||
|
BUILT_ALL=0
|
||||||
|
|
||||||
|
X86_RID="linux-x86"
|
||||||
|
X86_DEB_ARCH="i386"
|
||||||
|
|
||||||
declare -a BUILT_DEBS=()
|
declare -a BUILT_DEBS=()
|
||||||
|
|
||||||
@@ -32,6 +43,13 @@ die() {
|
|||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get_runtime_version() {
|
||||||
|
local sdk_ver="$1"
|
||||||
|
local prefix="${sdk_ver%.*}"
|
||||||
|
local patch="${sdk_ver##*.}"
|
||||||
|
echo "${prefix}.${patch#1}"
|
||||||
|
}
|
||||||
|
|
||||||
parse_args() {
|
parse_args() {
|
||||||
local first_arg="${1:-}"
|
local first_arg="${1:-}"
|
||||||
|
|
||||||
@@ -82,8 +100,8 @@ This script only supports: Debian."
|
|||||||
esac
|
esac
|
||||||
|
|
||||||
case "$HOST_ARCH" in
|
case "$HOST_ARCH" in
|
||||||
riscv64) ;;
|
x86_64|aarch64) ;;
|
||||||
*) die "Only supports riscv64" ;;
|
*) die "This script cross-compiles linux-x86 from x86_64 or aarch64 only." ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
current_kernel="$(uname -r)"
|
current_kernel="$(uname -r)"
|
||||||
@@ -91,11 +109,12 @@ This script only supports: Debian."
|
|||||||
|
|
||||||
[[ "$lowest" == "$MIN_KERNEL" ]] || die "Kernel $current_kernel is below $MIN_KERNEL"
|
[[ "$lowest" == "$MIN_KERNEL" ]] || die "Kernel $current_kernel is below $MIN_KERNEL"
|
||||||
echo "[OK] Kernel $current_kernel verified."
|
echo "[OK] Kernel $current_kernel verified."
|
||||||
|
echo "[OK] Host architecture: $HOST_ARCH"
|
||||||
|
echo "[OK] Target: $X86_RID (Debian $X86_DEB_ARCH)"
|
||||||
}
|
}
|
||||||
|
|
||||||
install_dependencies() {
|
install_dependencies() {
|
||||||
local install_ok=0
|
local install_ok=0
|
||||||
local tmp_dotnet=""
|
|
||||||
|
|
||||||
mkdir -p "$OUTPUT_DIR"
|
mkdir -p "$OUTPUT_DIR"
|
||||||
|
|
||||||
@@ -103,14 +122,21 @@ install_dependencies() {
|
|||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get -y install \
|
sudo apt-get -y install \
|
||||||
curl unzip tar jq rsync ca-certificates git dpkg-dev fakeroot file \
|
curl unzip tar jq rsync ca-certificates git dpkg-dev fakeroot file \
|
||||||
desktop-file-utils xdg-utils wget gcc make pkg-config \
|
desktop-file-utils xdg-utils wget python3
|
||||||
libicu-dev libssl-dev libfontconfig1 libfreetype6 zlib1g
|
|
||||||
|
|
||||||
mkdir -p "$HOME/.dotnet"
|
sudo dpkg --add-architecture "$X86_DEB_ARCH" || true
|
||||||
tmp_dotnet="$(mktemp -d)"
|
sudo apt-get update
|
||||||
curl -fL "$DOTNET_SDK_URL" -o "$tmp_dotnet/$DOTNET_RISCV_FILE"
|
sudo apt-get -y install \
|
||||||
tar -C "$HOME/.dotnet" -xzf "$tmp_dotnet/$DOTNET_RISCV_FILE"
|
"libc6:${X86_DEB_ARCH}" \
|
||||||
rm -rf "$tmp_dotnet"
|
"libgcc-s1:${X86_DEB_ARCH}" \
|
||||||
|
"libstdc++6:${X86_DEB_ARCH}" \
|
||||||
|
"zlib1g:${X86_DEB_ARCH}" \
|
||||||
|
"libfontconfig1:${X86_DEB_ARCH}" \
|
||||||
|
"libfreetype6:${X86_DEB_ARCH}"
|
||||||
|
|
||||||
|
wget -q https://dot.net/v1/dotnet-install.sh
|
||||||
|
chmod +x dotnet-install.sh
|
||||||
|
./dotnet-install.sh --version "$DOTNET_SDK_VERSION" --install-dir "$HOME/.dotnet"
|
||||||
|
|
||||||
export PATH="$HOME/.dotnet:$PATH"
|
export PATH="$HOME/.dotnet:$PATH"
|
||||||
export DOTNET_ROOT="$HOME/.dotnet"
|
export DOTNET_ROOT="$HOME/.dotnet"
|
||||||
@@ -120,11 +146,30 @@ install_dependencies() {
|
|||||||
|
|
||||||
if [[ "$install_ok" -ne 1 ]]; then
|
if [[ "$install_ok" -ne 1 ]]; then
|
||||||
echo "Could not auto-install dependencies for '$OS_ID'. Make sure these are available:"
|
echo "Could not auto-install dependencies for '$OS_ID'. Make sure these are available:"
|
||||||
echo "dotnet-riscv SDK, curl, unzip, tar, rsync, git, gcc, make, dpkg-deb, fakeroot, libicu-dev, libssl-dev"
|
echo "dotnet-sdk 10.x, i386 runtime libraries (libc6, libgcc-s1, libstdc++6, zlib1g, libfontconfig1, libfreetype6)"
|
||||||
|
echo "curl, unzip, tar, rsync, git, dpkg-deb, desktop-file-utils, xdg-utils"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
prepare_deb_target_arch() {
|
||||||
|
local target_arch="$X86_DEB_ARCH"
|
||||||
|
|
||||||
|
echo "[+] Prepare Debian multiarch environment for $X86_RID: $target_arch"
|
||||||
|
|
||||||
|
sudo dpkg --add-architecture "$target_arch" || true
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get -y install \
|
||||||
|
"libc6:${target_arch}" \
|
||||||
|
"libgcc-s1:${target_arch}" \
|
||||||
|
"libstdc++6:${target_arch}" \
|
||||||
|
"zlib1g:${target_arch}" \
|
||||||
|
"libfontconfig1:${target_arch}" \
|
||||||
|
"libfreetype6:${target_arch}"
|
||||||
|
|
||||||
|
echo "[OK] Debian target libraries prepared for $target_arch"
|
||||||
|
}
|
||||||
|
|
||||||
prepare_workspace() {
|
prepare_workspace() {
|
||||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
cd "$SCRIPT_DIR"
|
cd "$SCRIPT_DIR"
|
||||||
@@ -257,12 +302,121 @@ resolve_version() {
|
|||||||
echo "[*] GUI version resolved as: ${VERSION}"
|
echo "[*] GUI version resolved as: ${VERSION}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
download_nuget_packages() {
|
||||||
|
NUGET_CACHE_DIR="$(mktemp -d)"
|
||||||
|
|
||||||
|
local x86_rt
|
||||||
|
x86_rt="$(get_runtime_version "$DOTNET_X86_VERSION")"
|
||||||
|
|
||||||
|
local urls=(
|
||||||
|
"${DOTNET_NUPKG_BASE}/${x86_rt}/Microsoft.AspNetCore.App.Runtime.linux-x86.${x86_rt}.nupkg"
|
||||||
|
"${DOTNET_NUPKG_BASE}/${x86_rt}/Microsoft.NETCore.App.Host.linux-x86.${x86_rt}.nupkg"
|
||||||
|
"${DOTNET_NUPKG_BASE}/${x86_rt}/Microsoft.NETCore.App.Runtime.linux-x86.${x86_rt}.nupkg"
|
||||||
|
"${SKIASHARP_BASE}/v${SkiaSharp_VERSION}/SkiaSharp.NativeAssets.Linux.${SkiaSharp_VERSION}.nupkg"
|
||||||
|
"${SKIASHARP_BASE}/v${SkiaSharp_VERSION}/HarfBuzzSharp.NativeAssets.Linux.${HarfBuzzSharp_VERSION}.nupkg"
|
||||||
|
)
|
||||||
|
|
||||||
|
echo "[+] Downloading prebuilt NuGet packages to: $NUGET_CACHE_DIR"
|
||||||
|
local u pkg_name
|
||||||
|
for u in "${urls[@]}"; do
|
||||||
|
pkg_name="${u##*/}"
|
||||||
|
echo " -> $pkg_name"
|
||||||
|
curl -fL "$u" -o "$NUGET_CACHE_DIR/$pkg_name" || {
|
||||||
|
echo "[!] Failed to download $u" >&2
|
||||||
|
rm -rf "$NUGET_CACHE_DIR"
|
||||||
|
NUGET_CACHE_DIR=""
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "[OK] Prebuilt NuGet packages ready in $NUGET_CACHE_DIR"
|
||||||
|
}
|
||||||
|
|
||||||
|
prepare_dotnet_sdk() {
|
||||||
|
echo "[+] Using host .NET SDK for cross-target $X86_RID"
|
||||||
|
dotnet --info
|
||||||
|
DOTNET_SDK_DIR="${DOTNET_ROOT:-$(dirname "$(command -v dotnet)")}"
|
||||||
|
}
|
||||||
|
|
||||||
|
patch_x86_sdk() {
|
||||||
|
local sdk_props=""
|
||||||
|
local sdk_version=""
|
||||||
|
|
||||||
|
sdk_version="$(dotnet --version)"
|
||||||
|
sdk_props="$DOTNET_ROOT/sdk/${sdk_version}/Microsoft.NETCoreSdk.BundledVersions.props"
|
||||||
|
|
||||||
|
[[ -f "$sdk_props" ]] || die "SDK props file not found: $sdk_props"
|
||||||
|
|
||||||
|
echo "[+] Patching SDK: $sdk_props"
|
||||||
|
|
||||||
|
python3 - "$sdk_props" <<'PY'
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
path = Path(sys.argv[1])
|
||||||
|
text = path.read_text()
|
||||||
|
old = text
|
||||||
|
|
||||||
|
|
||||||
|
def add_attr_to_known_blocks(text, marker, attr):
|
||||||
|
start = 0
|
||||||
|
while True:
|
||||||
|
start = text.find(marker, start)
|
||||||
|
if start == -1:
|
||||||
|
break
|
||||||
|
|
||||||
|
end = text.find('/>', start)
|
||||||
|
if end == -1:
|
||||||
|
break
|
||||||
|
|
||||||
|
block = text[start:end]
|
||||||
|
attr_pos = block.find(attr)
|
||||||
|
if attr_pos != -1:
|
||||||
|
value_start = attr_pos + len(attr)
|
||||||
|
value_end = block.find('"', value_start)
|
||||||
|
if value_end != -1:
|
||||||
|
value = block[value_start:value_end]
|
||||||
|
values = value.split(';')
|
||||||
|
if 'linux-x86' not in values:
|
||||||
|
value = value + ';linux-x86'
|
||||||
|
block = block[:value_start] + value + block[value_end:]
|
||||||
|
text = text[:start] + block + text[end:]
|
||||||
|
end = start + len(block)
|
||||||
|
start = end + 2
|
||||||
|
return text
|
||||||
|
|
||||||
|
for framework in ('Microsoft.NETCore.App', 'Microsoft.AspNetCore.App'):
|
||||||
|
text = add_attr_to_known_blocks(
|
||||||
|
text,
|
||||||
|
f'<KnownFrameworkReference Include="{framework}"',
|
||||||
|
'RuntimePackRuntimeIdentifiers="',
|
||||||
|
)
|
||||||
|
|
||||||
|
text = add_attr_to_known_blocks(
|
||||||
|
text,
|
||||||
|
'<KnownAppHostPack',
|
||||||
|
'AppHostRuntimeIdentifiers="',
|
||||||
|
)
|
||||||
|
|
||||||
|
if text == old:
|
||||||
|
raise SystemExit('ERROR: No SDK RuntimePack/AppHost mapping was patched')
|
||||||
|
|
||||||
|
path.write_text(text)
|
||||||
|
PY
|
||||||
|
|
||||||
|
echo "===== Verify RuntimePackRuntimeIdentifiers ====="
|
||||||
|
grep -n 'RuntimePackRuntimeIdentifiers=' "$sdk_props" | grep 'linux-x86'
|
||||||
|
|
||||||
|
echo "===== Verify AppHostRuntimeIdentifiers ====="
|
||||||
|
grep -n 'AppHostRuntimeIdentifiers=' "$sdk_props" | grep 'linux-x86'
|
||||||
|
}
|
||||||
|
|
||||||
xray_url_for_rid() {
|
xray_url_for_rid() {
|
||||||
local rid="$1"
|
local rid="$1"
|
||||||
local ver="$2"
|
local ver="$2"
|
||||||
|
|
||||||
case "$rid" in
|
case "$rid" in
|
||||||
linux-riscv64) echo "https://github.com/XTLS/Xray-core/releases/download/v${ver}/Xray-linux-riscv64.zip" ;;
|
linux-x86) echo "https://github.com/XTLS/Xray-core/releases/download/v${ver}/Xray-linux-32.zip" ;;
|
||||||
*) return 1 ;;
|
*) return 1 ;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
@@ -272,7 +426,7 @@ singbox_url_for_rid() {
|
|||||||
local ver="$2"
|
local ver="$2"
|
||||||
|
|
||||||
case "$rid" in
|
case "$rid" in
|
||||||
linux-riscv64) echo "https://github.com/SagerNet/sing-box/releases/download/v${ver}/sing-box-${ver}-linux-riscv64.tar.gz" ;;
|
linux-x86) echo "https://github.com/SagerNet/sing-box/releases/download/v${ver}/sing-box-${ver}-linux-386.tar.gz" ;;
|
||||||
*) return 1 ;;
|
*) return 1 ;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
@@ -281,7 +435,7 @@ bundle_url_for_rid() {
|
|||||||
local rid="$1"
|
local rid="$1"
|
||||||
|
|
||||||
case "$rid" in
|
case "$rid" in
|
||||||
linux-riscv64) echo "https://raw.githubusercontent.com/2dust/v2rayN-core-bin/refs/heads/master/v2rayN-linux-riscv64.zip" ;;
|
linux-x86) echo "https://raw.githubusercontent.com/2dust/v2rayN-core-bin/refs/heads/master/v2rayN-linux-86.zip" ;;
|
||||||
*) return 1 ;;
|
*) return 1 ;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
@@ -303,7 +457,7 @@ download_xray() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
[[ -n "$ver" ]] || { echo "[xray] Failed to get version"; return 1; }
|
[[ -n "$ver" ]] || { echo "[xray] Failed to get version"; return 1; }
|
||||||
url="$(xray_url_for_rid "$rid" "$ver")" || { echo "[xray] Unsupported RID: $rid"; return 1; }
|
url="$(xray_url_for_rid "$rid" "$ver")" || { echo "[xray] Unsupported RID for upstream Xray: $rid (skipped)"; return 0; }
|
||||||
|
|
||||||
echo "[+] Download xray: $url"
|
echo "[+] Download xray: $url"
|
||||||
|
|
||||||
@@ -475,27 +629,38 @@ stage_runtime_assets() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe_target() {
|
describe_target() {
|
||||||
local short="$1"
|
printf '%s\n%s\n' "$X86_RID" "$X86_DEB_ARCH"
|
||||||
|
|
||||||
case "$short" in
|
|
||||||
riscv64) printf '%s\n%s\n' "linux-riscv64" "riscv64" ;;
|
|
||||||
*) echo "Unknown arch '$short' (use riscv64)" >&2; return 1 ;;
|
|
||||||
esac
|
|
||||||
}
|
}
|
||||||
|
|
||||||
publish_binary() {
|
publish_binary() {
|
||||||
local rid="$1"
|
local rid="$1"
|
||||||
|
|
||||||
|
patch_x86_sdk
|
||||||
dotnet clean "$PROJECT" -c Release
|
dotnet clean "$PROJECT" -c Release
|
||||||
rm -rf "$(dirname "$PROJECT")/bin/Release/net10.0" || true
|
rm -rf "$(dirname "$PROJECT")/bin/Release/net10.0" || true
|
||||||
dotnet restore "$PROJECT"
|
|
||||||
dotnet publish "$PROJECT" -c Release -r "$rid" -p:PublishSingleFile=false -p:SelfContained=true
|
dotnet restore "$PROJECT" \
|
||||||
|
-r "$rid" \
|
||||||
|
--source "$NUGET_CACHE_DIR" \
|
||||||
|
--source "https://api.nuget.org/v3/index.json" \
|
||||||
|
-p:SelfContained=true \
|
||||||
|
-p:EnableWindowsTargeting=true \
|
||||||
|
--force-evaluate
|
||||||
|
|
||||||
|
dotnet publish "$PROJECT" \
|
||||||
|
-c Release \
|
||||||
|
-r "$rid" \
|
||||||
|
--source "$NUGET_CACHE_DIR" \
|
||||||
|
--source "https://api.nuget.org/v3/index.json" \
|
||||||
|
-p:PublishSingleFile=false \
|
||||||
|
-p:SelfContained=true \
|
||||||
|
--no-restore
|
||||||
}
|
}
|
||||||
|
|
||||||
write_launcher_file() {
|
write_launcher_file() {
|
||||||
local stage="$1"
|
local stage="$1"
|
||||||
|
|
||||||
install -m 755 /dev/stdin "$stage/usr/bin/v2rayn" <<'EOF'
|
install -m 755 /dev/stdin "$stage/usr/bin/v2rayn" <<'EOF_LAUNCHER'
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
DIR="/opt/v2rayN"
|
DIR="/opt/v2rayN"
|
||||||
@@ -514,13 +679,13 @@ done
|
|||||||
echo "v2rayN launcher: no executable found in $DIR" >&2
|
echo "v2rayN launcher: no executable found in $DIR" >&2
|
||||||
ls -l "$DIR" >&2 || true
|
ls -l "$DIR" >&2 || true
|
||||||
exit 1
|
exit 1
|
||||||
EOF
|
EOF_LAUNCHER
|
||||||
}
|
}
|
||||||
|
|
||||||
write_desktop_file() {
|
write_desktop_file() {
|
||||||
local stage="$1"
|
local stage="$1"
|
||||||
|
|
||||||
install -m 644 /dev/stdin "$stage/usr/share/applications/v2rayn.desktop" <<'EOF'
|
install -m 644 /dev/stdin "$stage/usr/share/applications/v2rayn.desktop" <<'EOF_DESKTOP'
|
||||||
[Desktop Entry]
|
[Desktop Entry]
|
||||||
Type=Application
|
Type=Application
|
||||||
Name=v2rayN
|
Name=v2rayN
|
||||||
@@ -529,13 +694,13 @@ Exec=v2rayn
|
|||||||
Icon=v2rayn
|
Icon=v2rayn
|
||||||
Terminal=false
|
Terminal=false
|
||||||
Categories=Network;
|
Categories=Network;
|
||||||
EOF
|
EOF_DESKTOP
|
||||||
}
|
}
|
||||||
|
|
||||||
write_maintainer_scripts() {
|
write_maintainer_scripts() {
|
||||||
local debian_dir="$1"
|
local debian_dir="$1"
|
||||||
|
|
||||||
install -m 755 /dev/stdin "$debian_dir/postinst" <<'EOF'
|
install -m 755 /dev/stdin "$debian_dir/postinst" <<'EOF_POSTINST'
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -e
|
set -e
|
||||||
update-desktop-database /usr/share/applications >/dev/null 2>&1 || true
|
update-desktop-database /usr/share/applications >/dev/null 2>&1 || true
|
||||||
@@ -543,9 +708,9 @@ if command -v gtk-update-icon-cache >/dev/null 2>&1; then
|
|||||||
gtk-update-icon-cache -f /usr/share/icons/hicolor >/dev/null 2>&1 || true
|
gtk-update-icon-cache -f /usr/share/icons/hicolor >/dev/null 2>&1 || true
|
||||||
fi
|
fi
|
||||||
exit 0
|
exit 0
|
||||||
EOF
|
EOF_POSTINST
|
||||||
|
|
||||||
install -m 755 /dev/stdin "$debian_dir/postrm" <<'EOF'
|
install -m 755 /dev/stdin "$debian_dir/postrm" <<'EOF_POSTRM'
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -e
|
set -e
|
||||||
update-desktop-database /usr/share/applications >/dev/null 2>&1 || true
|
update-desktop-database /usr/share/applications >/dev/null 2>&1 || true
|
||||||
@@ -553,7 +718,7 @@ if command -v gtk-update-icon-cache >/dev/null 2>&1; then
|
|||||||
gtk-update-icon-cache -f /usr/share/icons/hicolor >/dev/null 2>&1 || true
|
gtk-update-icon-cache -f /usr/share/icons/hicolor >/dev/null 2>&1 || true
|
||||||
fi
|
fi
|
||||||
exit 0
|
exit 0
|
||||||
EOF
|
EOF_POSTRM
|
||||||
}
|
}
|
||||||
|
|
||||||
package_binary() {
|
package_binary() {
|
||||||
@@ -595,10 +760,12 @@ package_binary() {
|
|||||||
write_desktop_file "$stage"
|
write_desktop_file "$stage"
|
||||||
write_maintainer_scripts "$debian_dir"
|
write_maintainer_scripts "$debian_dir"
|
||||||
|
|
||||||
extra_depends="libc6 (>= 2.39), fontconfig (>= 2.15.0), desktop-file-utils (>= 0.26), xdg-utils (>= 1.1.3), coreutils (>= 9.4), bash (>= 5.2.21), libfreetype6 (>= 2.13)"
|
prepare_deb_target_arch
|
||||||
|
|
||||||
|
extra_depends="libc6 (>= 2.36), fontconfig (>= 2.14.1), desktop-file-utils (>= 0.26), xdg-utils (>= 1.1.3), coreutils (>= 9.1), bash (>= 5.2.15), libfreetype6 (>= 2.12)"
|
||||||
|
|
||||||
mkdir -p "$workdir/debian"
|
mkdir -p "$workdir/debian"
|
||||||
cat > "$workdir/debian/control" <<EOF
|
cat > "$workdir/debian/control" <<EOF_CONTROL
|
||||||
Source: v2rayn
|
Source: v2rayn
|
||||||
Section: net
|
Section: net
|
||||||
Priority: optional
|
Priority: optional
|
||||||
@@ -608,7 +775,7 @@ Standards-Version: 4.7.0
|
|||||||
Package: v2rayn
|
Package: v2rayn
|
||||||
Architecture: ${deb_arch}
|
Architecture: ${deb_arch}
|
||||||
Description: v2rayN
|
Description: v2rayN
|
||||||
EOF
|
EOF_CONTROL
|
||||||
|
|
||||||
multiarch="$(dpkg-architecture -a"$deb_arch" -qDEB_HOST_MULTIARCH)"
|
multiarch="$(dpkg-architecture -a"$deb_arch" -qDEB_HOST_MULTIARCH)"
|
||||||
sys_libdir="/lib/$multiarch"
|
sys_libdir="/lib/$multiarch"
|
||||||
@@ -629,7 +796,7 @@ EOF
|
|||||||
-l"$sys_usrlibdir" \
|
-l"$sys_usrlibdir" \
|
||||||
-T"$debian_dir/substvars" \
|
-T"$debian_dir/substvars" \
|
||||||
"${ELF_FILES[@]}"
|
"${ELF_FILES[@]}"
|
||||||
) >/dev/null 2>&1 || true
|
) || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
shlibs_depends="$(sed -n 's/^shlibs:Depends=//p' "$debian_dir/substvars" | head -n1 || true)"
|
shlibs_depends="$(sed -n 's/^shlibs:Depends=//p' "$debian_dir/substvars" | head -n1 || true)"
|
||||||
@@ -644,7 +811,7 @@ EOF
|
|||||||
final_depends="${extra_depends}"
|
final_depends="${extra_depends}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
cat > "$debian_dir/control" <<EOF
|
cat > "$debian_dir/control" <<EOF_CONTROL2
|
||||||
Package: v2rayn
|
Package: v2rayn
|
||||||
Version: ${VERSION}
|
Version: ${VERSION}
|
||||||
Architecture: ${deb_arch}
|
Architecture: ${deb_arch}
|
||||||
@@ -656,7 +823,7 @@ Depends: ${final_depends}
|
|||||||
Description: v2rayN (Avalonia) GUI client for Linux
|
Description: v2rayN (Avalonia) GUI client for Linux
|
||||||
Support vless / vmess / Trojan / http / socks / Anytls / Hysteria2 /
|
Support vless / vmess / Trojan / http / socks / Anytls / Hysteria2 /
|
||||||
Shadowsocks / tuic / WireGuard.
|
Shadowsocks / tuic / WireGuard.
|
||||||
EOF
|
EOF_CONTROL2
|
||||||
|
|
||||||
find "$stage/opt/v2rayN" -type d -exec chmod 0755 {} +
|
find "$stage/opt/v2rayN" -type d -exec chmod 0755 {} +
|
||||||
find "$stage/opt/v2rayN" -type f -exec chmod 0644 {} +
|
find "$stage/opt/v2rayN" -type f -exec chmod 0644 {} +
|
||||||
@@ -670,21 +837,13 @@ EOF
|
|||||||
BUILT_DEBS+=("$deb_out")
|
BUILT_DEBS+=("$deb_out")
|
||||||
}
|
}
|
||||||
|
|
||||||
select_targets() {
|
|
||||||
printf '%s\n' riscv64
|
|
||||||
}
|
|
||||||
|
|
||||||
build_one_target() {
|
build_one_target() {
|
||||||
local short="$1"
|
local short="x86"
|
||||||
local meta=()
|
local rid="$X86_RID"
|
||||||
local rid=""
|
local deb_arch="$X86_DEB_ARCH"
|
||||||
local deb_arch=""
|
|
||||||
|
|
||||||
mapfile -t meta < <(describe_target "$short") || return 1
|
|
||||||
rid="${meta[0]}"
|
|
||||||
deb_arch="${meta[1]}"
|
|
||||||
|
|
||||||
echo "[*] Building for target: $short (RID=$rid, DEB arch=$deb_arch)"
|
echo "[*] Building for target: $short (RID=$rid, DEB arch=$deb_arch)"
|
||||||
|
prepare_dotnet_sdk
|
||||||
publish_binary "$rid"
|
publish_binary "$rid"
|
||||||
package_binary "$short" "$rid" "$deb_arch"
|
package_binary "$short" "$rid" "$deb_arch"
|
||||||
}
|
}
|
||||||
@@ -706,20 +865,17 @@ print_summary() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
local targets=()
|
|
||||||
local arch=""
|
|
||||||
|
|
||||||
parse_args "$@"
|
parse_args "$@"
|
||||||
detect_environment
|
detect_environment
|
||||||
install_dependencies
|
install_dependencies
|
||||||
prepare_workspace
|
prepare_workspace
|
||||||
resolve_version
|
resolve_version
|
||||||
|
|
||||||
mapfile -t targets < <(select_targets)
|
download_nuget_packages || die "Failed to download prebuilt NuGet packages."
|
||||||
|
|
||||||
for arch in "${targets[@]}"; do
|
build_one_target
|
||||||
build_one_target "$arch"
|
|
||||||
done
|
rm -rf "${NUGET_CACHE_DIR:-}" || true
|
||||||
|
|
||||||
print_summary
|
print_summary
|
||||||
}
|
}
|
||||||
Regular → Executable
+243
-12
@@ -14,6 +14,30 @@ PKGROOT="v2rayN-publish"
|
|||||||
PROJECT_HINT="v2rayN.Desktop/v2rayN.Desktop.csproj"
|
PROJECT_HINT="v2rayN.Desktop/v2rayN.Desktop.csproj"
|
||||||
OUTPUT_DIR="${HOME}/debbuild"
|
OUTPUT_DIR="${HOME}/debbuild"
|
||||||
|
|
||||||
|
NUGET_CACHE_DIR=""
|
||||||
|
DOTNET_NUPKG_BASE="${DOTNET_NUPKG_BASE:-https://github.com/autorepobot/dotnet-nupkg-prebuild/releases/download}"
|
||||||
|
SKIASHARP_BASE="${SKIASHARP_BASE:-https://github.com/autorepobot/SkiaSharp-ppc64le/releases/download}"
|
||||||
|
SkiaSharp_VERSION="${SkiaSharp_VERSION:-3.119.4}"
|
||||||
|
HarfBuzzSharp_VERSION="${HarfBuzzSharp_VERSION:-8.3.1.5}"
|
||||||
|
|
||||||
|
DOTNET_SDK_VERSION="${DOTNET_SDK_VERSION:-10.0.114}"
|
||||||
|
|
||||||
|
DOTNET_RISCV_VERSION="${DOTNET_RISCV_VERSION:-10.0.114}"
|
||||||
|
DOTNET_RISCV_BASE="${DOTNET_RISCV_BASE:-https://github.com/autorepobot/dotnet-riscv/releases/download}"
|
||||||
|
DOTNET_RISCV_FILE="dotnet-sdk-${DOTNET_RISCV_VERSION}-linux-riscv64.tar.gz"
|
||||||
|
DOTNET_RISCV_SDK_URL="${DOTNET_RISCV_SDK_URL:-${DOTNET_RISCV_BASE}/${DOTNET_RISCV_VERSION}/${DOTNET_RISCV_FILE}}"
|
||||||
|
|
||||||
|
DOTNET_PPC64LE_VERSION="${DOTNET_PPC64LE_VERSION:-10.0.114}"
|
||||||
|
DOTNET_PPC64LE_BASE="${DOTNET_PPC64LE_BASE:-https://github.com/IBM/dotnet-s390x/releases/download}"
|
||||||
|
DOTNET_PPC64LE_FILE="dotnet-sdk-${DOTNET_PPC64LE_VERSION}-linux-ppc64le.tar.gz"
|
||||||
|
DOTNET_PPC64LE_SDK_URL="${DOTNET_PPC64LE_SDK_URL:-${DOTNET_PPC64LE_BASE}/v${DOTNET_PPC64LE_VERSION}/${DOTNET_PPC64LE_FILE}}"
|
||||||
|
|
||||||
|
DOTNET_LOONGARCH_VERSION="${DOTNET_LOONGARCH_VERSION:-10.0.114}"
|
||||||
|
DOTNET_LOONGARCH_TAG="${DOTNET_LOONGARCH_TAG:-v10.0.114-loongarch64}"
|
||||||
|
DOTNET_LOONGARCH_BASE="${DOTNET_LOONGARCH_BASE:-https://github.com/loongson/dotnet/releases/download}"
|
||||||
|
DOTNET_LOONGARCH_FILE="dotnet-sdk-${DOTNET_LOONGARCH_VERSION}-linux-loongarch64.tar.gz"
|
||||||
|
DOTNET_LOONGARCH_SDK_URL="${DOTNET_LOONGARCH_SDK_URL:-${DOTNET_LOONGARCH_BASE}/${DOTNET_LOONGARCH_TAG}/${DOTNET_LOONGARCH_FILE}}"
|
||||||
|
|
||||||
OS_ID=""
|
OS_ID=""
|
||||||
OS_NAME=""
|
OS_NAME=""
|
||||||
OS_VERSION_ID=""
|
OS_VERSION_ID=""
|
||||||
@@ -21,6 +45,7 @@ HOST_ARCH=""
|
|||||||
SCRIPT_DIR=""
|
SCRIPT_DIR=""
|
||||||
PROJECT=""
|
PROJECT=""
|
||||||
VERSION=""
|
VERSION=""
|
||||||
|
BUILT_ALL=0
|
||||||
|
|
||||||
declare -a BUILT_DEBS=()
|
declare -a BUILT_DEBS=()
|
||||||
|
|
||||||
@@ -29,6 +54,13 @@ die() {
|
|||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get_runtime_version() {
|
||||||
|
local sdk_ver="$1"
|
||||||
|
local prefix="${sdk_ver%.*}"
|
||||||
|
local patch="${sdk_ver##*.}"
|
||||||
|
echo "${prefix}.${patch#1}"
|
||||||
|
}
|
||||||
|
|
||||||
parse_args() {
|
parse_args() {
|
||||||
local first_arg="${1:-}"
|
local first_arg="${1:-}"
|
||||||
|
|
||||||
@@ -80,8 +112,8 @@ This script only supports: Debian."
|
|||||||
esac
|
esac
|
||||||
|
|
||||||
case "$HOST_ARCH" in
|
case "$HOST_ARCH" in
|
||||||
x86_64|aarch64) ;;
|
x86_64|aarch64|riscv64|ppc64le|loongarch64) ;;
|
||||||
*) die "Only supports aarch64 / x86_64" ;;
|
*) die "Only supports x86_64 / aarch64 / riscv64 / ppc64le / loongarch64" ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
current_kernel="$(uname -r)"
|
current_kernel="$(uname -r)"
|
||||||
@@ -106,9 +138,13 @@ install_dependencies() {
|
|||||||
case "$HOST_ARCH" in
|
case "$HOST_ARCH" in
|
||||||
aarch64) foreign_arch="amd64" ;;
|
aarch64) foreign_arch="amd64" ;;
|
||||||
x86_64) foreign_arch="arm64" ;;
|
x86_64) foreign_arch="arm64" ;;
|
||||||
*) die "Only supports aarch64 / x86_64" ;;
|
riscv64|ppc64le|loongarch64)
|
||||||
|
foreign_arch=""
|
||||||
|
;;
|
||||||
|
*) die "Only supports x86_64 / aarch64 / riscv64 / ppc64le / loongarch64" ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
if [[ -n "$foreign_arch" ]]; then
|
||||||
sudo dpkg --add-architecture "$foreign_arch" || true
|
sudo dpkg --add-architecture "$foreign_arch" || true
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get -y install \
|
sudo apt-get -y install \
|
||||||
@@ -117,15 +153,23 @@ install_dependencies() {
|
|||||||
"libstdc++6:${foreign_arch}" \
|
"libstdc++6:${foreign_arch}" \
|
||||||
"zlib1g:${foreign_arch}" \
|
"zlib1g:${foreign_arch}" \
|
||||||
"libfontconfig1:${foreign_arch}"
|
"libfontconfig1:${foreign_arch}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$HOST_ARCH" in
|
||||||
|
x86_64|aarch64)
|
||||||
wget -q https://dot.net/v1/dotnet-install.sh
|
wget -q https://dot.net/v1/dotnet-install.sh
|
||||||
chmod +x dotnet-install.sh
|
chmod +x dotnet-install.sh
|
||||||
./dotnet-install.sh --channel 10.0.1xx --install-dir "$HOME/.dotnet"
|
./dotnet-install.sh --version "$DOTNET_SDK_VERSION" --install-dir "$HOME/.dotnet"
|
||||||
|
|
||||||
export PATH="$HOME/.dotnet:$PATH"
|
export PATH="$HOME/.dotnet:$PATH"
|
||||||
export DOTNET_ROOT="$HOME/.dotnet"
|
export DOTNET_ROOT="$HOME/.dotnet"
|
||||||
|
|
||||||
dotnet --info >/dev/null 2>&1 && install_ok=1
|
dotnet --info >/dev/null 2>&1 && install_ok=1
|
||||||
|
;;
|
||||||
|
riscv64|ppc64le|loongarch64)
|
||||||
|
install_ok=1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$install_ok" -ne 1 ]]; then
|
if [[ "$install_ok" -ne 1 ]]; then
|
||||||
@@ -135,6 +179,61 @@ install_dependencies() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
prepare_deb_target_arch() {
|
||||||
|
local short="$1"
|
||||||
|
local target_arch=""
|
||||||
|
|
||||||
|
case "$HOST_ARCH:$short" in
|
||||||
|
x86_64:ppc64le|aarch64:ppc64le)
|
||||||
|
target_arch="ppc64el"
|
||||||
|
;;
|
||||||
|
x86_64:riscv64|aarch64:riscv64)
|
||||||
|
target_arch="riscv64"
|
||||||
|
;;
|
||||||
|
x86_64:loong64|aarch64:loong64)
|
||||||
|
target_arch="loong64"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo "[+] Prepare Debian multiarch environment for $short: $target_arch"
|
||||||
|
|
||||||
|
sudo dpkg --add-architecture "$target_arch" || true
|
||||||
|
|
||||||
|
if [[ "$target_arch" == "loong64" ]]; then
|
||||||
|
echo "[+] Add Loong13 Debian Trixie source for loong64"
|
||||||
|
|
||||||
|
cat <<'EOF' | sudo tee /etc/apt/sources.list.d/loong64-loong13.list >/dev/null
|
||||||
|
deb [arch=loong64] http://loong13.debian.net/debian-loong64/ trixie main contrib non-free non-free-firmware
|
||||||
|
deb [arch=loong64] http://loong13.debian.net/debian-loong64-security/ trixie-security main contrib non-free non-free-firmware
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "[+] Install Loong13 archive keyring"
|
||||||
|
|
||||||
|
tmp_keyring="$(mktemp -d)"
|
||||||
|
curl -fL \
|
||||||
|
"http://loong13.debian.net/debian-loong64/pool/main/d/debian-loong64-non-official-archive-keyring/debian-loong64-non-official-archive-keyring_2025.12.22_all.deb" \
|
||||||
|
-o "$tmp_keyring/loong13-keyring.deb"
|
||||||
|
|
||||||
|
sudo dpkg -i "$tmp_keyring/loong13-keyring.deb"
|
||||||
|
rm -rf "$tmp_keyring"
|
||||||
|
fi
|
||||||
|
|
||||||
|
sudo apt-get update
|
||||||
|
|
||||||
|
sudo apt-get -y install \
|
||||||
|
"libc6:${target_arch}" \
|
||||||
|
"libgcc-s1:${target_arch}" \
|
||||||
|
"libstdc++6:${target_arch}" \
|
||||||
|
"zlib1g:${target_arch}" \
|
||||||
|
"libfontconfig1:${target_arch}" \
|
||||||
|
"libfreetype6:${target_arch}"
|
||||||
|
|
||||||
|
echo "[OK] Debian target libraries prepared for $target_arch"
|
||||||
|
}
|
||||||
|
|
||||||
prepare_workspace() {
|
prepare_workspace() {
|
||||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
cd "$SCRIPT_DIR"
|
cd "$SCRIPT_DIR"
|
||||||
@@ -267,6 +366,104 @@ resolve_version() {
|
|||||||
echo "[*] GUI version resolved as: ${VERSION}"
|
echo "[*] GUI version resolved as: ${VERSION}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
download_nuget_packages() {
|
||||||
|
NUGET_CACHE_DIR="$(mktemp -d)"
|
||||||
|
|
||||||
|
local riscv_rt ppc64le_rt loong_rt
|
||||||
|
riscv_rt="$(get_runtime_version "$DOTNET_RISCV_VERSION")"
|
||||||
|
ppc64le_rt="$(get_runtime_version "$DOTNET_PPC64LE_VERSION")"
|
||||||
|
loong_rt="$(get_runtime_version "$DOTNET_LOONGARCH_VERSION")"
|
||||||
|
|
||||||
|
local urls=(
|
||||||
|
"${DOTNET_NUPKG_BASE}/${loong_rt}/Microsoft.AspNetCore.App.Runtime.linux-loongarch64.${loong_rt}.nupkg"
|
||||||
|
"${DOTNET_NUPKG_BASE}/${ppc64le_rt}/Microsoft.AspNetCore.App.Runtime.linux-ppc64le.${ppc64le_rt}.nupkg"
|
||||||
|
"${DOTNET_NUPKG_BASE}/${riscv_rt}/Microsoft.AspNetCore.App.Runtime.linux-riscv64.${riscv_rt}.nupkg"
|
||||||
|
"${DOTNET_NUPKG_BASE}/${loong_rt}/Microsoft.NETCore.App.Host.linux-loongarch64.${loong_rt}.nupkg"
|
||||||
|
"${DOTNET_NUPKG_BASE}/${ppc64le_rt}/Microsoft.NETCore.App.Host.linux-ppc64le.${ppc64le_rt}.nupkg"
|
||||||
|
"${DOTNET_NUPKG_BASE}/${riscv_rt}/Microsoft.NETCore.App.Host.linux-riscv64.${riscv_rt}.nupkg"
|
||||||
|
"${DOTNET_NUPKG_BASE}/${loong_rt}/Microsoft.NETCore.App.Runtime.linux-loongarch64.${loong_rt}.nupkg"
|
||||||
|
"${DOTNET_NUPKG_BASE}/${ppc64le_rt}/Microsoft.NETCore.App.Runtime.linux-ppc64le.${ppc64le_rt}.nupkg"
|
||||||
|
"${DOTNET_NUPKG_BASE}/${riscv_rt}/Microsoft.NETCore.App.Runtime.linux-riscv64.${riscv_rt}.nupkg"
|
||||||
|
"${SKIASHARP_BASE}/v${SkiaSharp_VERSION}/SkiaSharp.NativeAssets.Linux.${SkiaSharp_VERSION}.nupkg"
|
||||||
|
"${SKIASHARP_BASE}/v${SkiaSharp_VERSION}/HarfBuzzSharp.NativeAssets.Linux.${HarfBuzzSharp_VERSION}.nupkg"
|
||||||
|
)
|
||||||
|
|
||||||
|
echo "[+] Downloading prebuilt NuGet packages to: $NUGET_CACHE_DIR"
|
||||||
|
local u pkg_name
|
||||||
|
for u in "${urls[@]}"; do
|
||||||
|
pkg_name="${u##*/}"
|
||||||
|
echo " -> $pkg_name"
|
||||||
|
curl -fL "$u" -o "$NUGET_CACHE_DIR/$pkg_name" || {
|
||||||
|
echo "[!] Failed to download $u" >&2
|
||||||
|
rm -rf "$NUGET_CACHE_DIR"
|
||||||
|
NUGET_CACHE_DIR=""
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "[OK] Prebuilt NuGet packages ready in $NUGET_CACHE_DIR"
|
||||||
|
}
|
||||||
|
|
||||||
|
prepare_dotnet_sdk() {
|
||||||
|
local short="$1"
|
||||||
|
local sdk_url=""
|
||||||
|
local sdk_file=""
|
||||||
|
local sdk_dir=""
|
||||||
|
local tmp=""
|
||||||
|
|
||||||
|
case "$HOST_ARCH:$short" in
|
||||||
|
x86_64:x64|aarch64:arm64)
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
riscv64:riscv64)
|
||||||
|
sdk_url="$DOTNET_RISCV_SDK_URL"
|
||||||
|
sdk_file="$DOTNET_RISCV_FILE"
|
||||||
|
;;
|
||||||
|
ppc64le:ppc64le)
|
||||||
|
sdk_url="$DOTNET_PPC64LE_SDK_URL"
|
||||||
|
sdk_file="$DOTNET_PPC64LE_FILE"
|
||||||
|
;;
|
||||||
|
loongarch64:loong64)
|
||||||
|
sdk_url="$DOTNET_LOONGARCH_SDK_URL"
|
||||||
|
sdk_file="$DOTNET_LOONGARCH_FILE"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
sdk_dir="$(mktemp -d)"
|
||||||
|
tmp="$(mktemp -d)"
|
||||||
|
|
||||||
|
echo "[+] Download .NET SDK for $short: $sdk_url"
|
||||||
|
|
||||||
|
curl -fL "$sdk_url" -o "$tmp/$sdk_file" || {
|
||||||
|
rm -rf "$tmp" "$sdk_dir"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
tar -C "$sdk_dir" -xzf "$tmp/$sdk_file" || {
|
||||||
|
rm -rf "$tmp" "$sdk_dir"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
rm -rf "$tmp"
|
||||||
|
|
||||||
|
[[ -x "$sdk_dir/dotnet" ]] || {
|
||||||
|
rm -rf "$sdk_dir"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
export DOTNET_ROOT="$sdk_dir"
|
||||||
|
export PATH="$DOTNET_ROOT:$PATH"
|
||||||
|
hash -r
|
||||||
|
|
||||||
|
echo "[OK] .NET SDK for $short extracted to: $sdk_dir"
|
||||||
|
dotnet --info
|
||||||
|
|
||||||
|
DOTNET_SDK_DIR="$sdk_dir"
|
||||||
|
}
|
||||||
|
|
||||||
xray_url_for_rid() {
|
xray_url_for_rid() {
|
||||||
local rid="$1"
|
local rid="$1"
|
||||||
local ver="$2"
|
local ver="$2"
|
||||||
@@ -274,6 +471,9 @@ xray_url_for_rid() {
|
|||||||
case "$rid" in
|
case "$rid" in
|
||||||
linux-x64) echo "https://github.com/XTLS/Xray-core/releases/download/v${ver}/Xray-linux-64.zip" ;;
|
linux-x64) echo "https://github.com/XTLS/Xray-core/releases/download/v${ver}/Xray-linux-64.zip" ;;
|
||||||
linux-arm64) echo "https://github.com/XTLS/Xray-core/releases/download/v${ver}/Xray-linux-arm64-v8a.zip" ;;
|
linux-arm64) echo "https://github.com/XTLS/Xray-core/releases/download/v${ver}/Xray-linux-arm64-v8a.zip" ;;
|
||||||
|
linux-riscv64) echo "https://github.com/XTLS/Xray-core/releases/download/v${ver}/Xray-linux-riscv64.zip" ;;
|
||||||
|
linux-ppc64le) echo "https://github.com/XTLS/Xray-core/releases/download/v${ver}/Xray-linux-ppc64le.zip" ;;
|
||||||
|
linux-loongarch64) echo "https://github.com/XTLS/Xray-core/releases/download/v${ver}/Xray-linux-loong64.zip" ;;
|
||||||
*) return 1 ;;
|
*) return 1 ;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
@@ -285,6 +485,9 @@ singbox_url_for_rid() {
|
|||||||
case "$rid" in
|
case "$rid" in
|
||||||
linux-x64) echo "https://github.com/SagerNet/sing-box/releases/download/v${ver}/sing-box-${ver}-linux-amd64.tar.gz" ;;
|
linux-x64) echo "https://github.com/SagerNet/sing-box/releases/download/v${ver}/sing-box-${ver}-linux-amd64.tar.gz" ;;
|
||||||
linux-arm64) echo "https://github.com/SagerNet/sing-box/releases/download/v${ver}/sing-box-${ver}-linux-arm64.tar.gz" ;;
|
linux-arm64) echo "https://github.com/SagerNet/sing-box/releases/download/v${ver}/sing-box-${ver}-linux-arm64.tar.gz" ;;
|
||||||
|
linux-riscv64) echo "https://github.com/SagerNet/sing-box/releases/download/v${ver}/sing-box-${ver}-linux-riscv64.tar.gz" ;;
|
||||||
|
linux-ppc64le) echo "https://github.com/SagerNet/sing-box/releases/download/v${ver}/sing-box-${ver}-linux-ppc64le.tar.gz" ;;
|
||||||
|
linux-loongarch64) echo "https://github.com/SagerNet/sing-box/releases/download/v${ver}/sing-box-${ver}-linux-loong64.tar.gz" ;;
|
||||||
*) return 1 ;;
|
*) return 1 ;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
@@ -295,6 +498,9 @@ bundle_url_for_rid() {
|
|||||||
case "$rid" in
|
case "$rid" in
|
||||||
linux-x64) echo "https://raw.githubusercontent.com/2dust/v2rayN-core-bin/refs/heads/master/v2rayN-linux-64.zip" ;;
|
linux-x64) echo "https://raw.githubusercontent.com/2dust/v2rayN-core-bin/refs/heads/master/v2rayN-linux-64.zip" ;;
|
||||||
linux-arm64) echo "https://raw.githubusercontent.com/2dust/v2rayN-core-bin/refs/heads/master/v2rayN-linux-arm64.zip" ;;
|
linux-arm64) echo "https://raw.githubusercontent.com/2dust/v2rayN-core-bin/refs/heads/master/v2rayN-linux-arm64.zip" ;;
|
||||||
|
linux-riscv64) echo "https://raw.githubusercontent.com/2dust/v2rayN-core-bin/refs/heads/master/v2rayN-linux-riscv64.zip" ;;
|
||||||
|
linux-ppc64le) echo "https://raw.githubusercontent.com/2dust/v2rayN-core-bin/refs/heads/master/v2rayN-linux-ppc64le.zip" ;;
|
||||||
|
linux-loongarch64) echo "https://raw.githubusercontent.com/2dust/v2rayN-core-bin/refs/heads/master/v2rayN-linux-loong64.zip" ;;
|
||||||
*) return 1 ;;
|
*) return 1 ;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
@@ -316,7 +522,7 @@ download_xray() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
[[ -n "$ver" ]] || { echo "[xray] Failed to get version"; return 1; }
|
[[ -n "$ver" ]] || { echo "[xray] Failed to get version"; return 1; }
|
||||||
url="$(xray_url_for_rid "$rid" "$ver")" || { echo "[xray] Unsupported RID: $rid"; return 1; }
|
url="$(xray_url_for_rid "$rid" "$ver")" || { echo "[xray] Unsupported RID for upstream Xray: $rid (skipped)"; return 0; }
|
||||||
|
|
||||||
echo "[+] Download xray: $url"
|
echo "[+] Download xray: $url"
|
||||||
|
|
||||||
@@ -476,7 +682,7 @@ stage_runtime_assets() {
|
|||||||
|
|
||||||
if [[ "$FORCE_NETCORE" -eq 0 ]]; then
|
if [[ "$FORCE_NETCORE" -eq 0 ]]; then
|
||||||
if populate_assets_zip_mode "$outroot" "$rid"; then
|
if populate_assets_zip_mode "$outroot" "$rid"; then
|
||||||
echo "[*] Using v2rayN bundle bin assets."
|
echo "[*] Using v2rayN bundle archive."
|
||||||
else
|
else
|
||||||
echo "[*] Bundle failed, fallback to separate core + rules."
|
echo "[*] Bundle failed, fallback to separate core + rules."
|
||||||
populate_assets_netcore_mode "$outroot" "$rid"
|
populate_assets_netcore_mode "$outroot" "$rid"
|
||||||
@@ -493,7 +699,10 @@ describe_target() {
|
|||||||
case "$short" in
|
case "$short" in
|
||||||
x64) printf '%s\n%s\n' "linux-x64" "amd64" ;;
|
x64) printf '%s\n%s\n' "linux-x64" "amd64" ;;
|
||||||
arm64) printf '%s\n%s\n' "linux-arm64" "arm64" ;;
|
arm64) printf '%s\n%s\n' "linux-arm64" "arm64" ;;
|
||||||
*) echo "Unknown arch '$short' (use x64|arm64)" >&2; return 1 ;;
|
riscv64) printf '%s\n%s\n' "linux-riscv64" "riscv64" ;;
|
||||||
|
ppc64le) printf '%s\n%s\n' "linux-ppc64le" "ppc64el" ;;
|
||||||
|
loong64) printf '%s\n%s\n' "linux-loongarch64" "loong64" ;;
|
||||||
|
*) echo "Unknown arch '$short' (use x64|arm64|riscv64|ppc64le|loong64)" >&2; return 1 ;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -502,8 +711,18 @@ publish_binary() {
|
|||||||
|
|
||||||
dotnet clean "$PROJECT" -c Release
|
dotnet clean "$PROJECT" -c Release
|
||||||
rm -rf "$(dirname "$PROJECT")/bin/Release/net10.0" || true
|
rm -rf "$(dirname "$PROJECT")/bin/Release/net10.0" || true
|
||||||
dotnet restore "$PROJECT"
|
dotnet restore "$PROJECT" \
|
||||||
dotnet publish "$PROJECT" -c Release -r "$rid" -p:PublishSingleFile=false -p:SelfContained=true
|
-r "$rid" \
|
||||||
|
--source "$NUGET_CACHE_DIR" \
|
||||||
|
--source "https://api.nuget.org/v3/index.json"
|
||||||
|
dotnet publish "$PROJECT" \
|
||||||
|
-c Release \
|
||||||
|
-r "$rid" \
|
||||||
|
--source "$NUGET_CACHE_DIR" \
|
||||||
|
--source "https://api.nuget.org/v3/index.json" \
|
||||||
|
-p:PublishSingleFile=false \
|
||||||
|
-p:SelfContained=true \
|
||||||
|
--no-restore
|
||||||
}
|
}
|
||||||
|
|
||||||
write_launcher_file() {
|
write_launcher_file() {
|
||||||
@@ -609,6 +828,8 @@ package_binary() {
|
|||||||
write_desktop_file "$stage"
|
write_desktop_file "$stage"
|
||||||
write_maintainer_scripts "$debian_dir"
|
write_maintainer_scripts "$debian_dir"
|
||||||
|
|
||||||
|
prepare_deb_target_arch "$short"
|
||||||
|
|
||||||
extra_depends="libc6 (>= 2.39), fontconfig (>= 2.15.0), desktop-file-utils (>= 0.26), xdg-utils (>= 1.1.3), coreutils (>= 9.4), bash (>= 5.2.21), libfreetype6 (>= 2.13)"
|
extra_depends="libc6 (>= 2.39), fontconfig (>= 2.15.0), desktop-file-utils (>= 0.26), xdg-utils (>= 1.1.3), coreutils (>= 9.4), bash (>= 5.2.21), libfreetype6 (>= 2.13)"
|
||||||
|
|
||||||
mkdir -p "$workdir/debian"
|
mkdir -p "$workdir/debian"
|
||||||
@@ -643,7 +864,7 @@ EOF
|
|||||||
-l"$sys_usrlibdir" \
|
-l"$sys_usrlibdir" \
|
||||||
-T"$debian_dir/substvars" \
|
-T"$debian_dir/substvars" \
|
||||||
"${ELF_FILES[@]}"
|
"${ELF_FILES[@]}"
|
||||||
) >/dev/null 2>&1 || true
|
) || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
shlibs_depends="$(sed -n 's/^shlibs:Depends=//p' "$debian_dir/substvars" | head -n1 || true)"
|
shlibs_depends="$(sed -n 's/^shlibs:Depends=//p' "$debian_dir/substvars" | head -n1 || true)"
|
||||||
@@ -686,9 +907,12 @@ EOF
|
|||||||
|
|
||||||
select_targets() {
|
select_targets() {
|
||||||
case "${ARCH_OVERRIDE:-}" in
|
case "${ARCH_OVERRIDE:-}" in
|
||||||
all) printf '%s\n' x64 arm64 ;;
|
all) printf '%s\n' x64 arm64 riscv64 ppc64le loong64 ;;
|
||||||
x64|amd64) printf '%s\n' x64 ;;
|
x64|amd64) printf '%s\n' x64 ;;
|
||||||
arm64|aarch64) printf '%s\n' arm64 ;;
|
arm64|aarch64) printf '%s\n' arm64 ;;
|
||||||
|
riscv64) printf '%s\n' riscv64 ;;
|
||||||
|
ppc64le|ppc64el) printf '%s\n' ppc64le ;;
|
||||||
|
loong64|loongarch64) printf '%s\n' loong64 ;;
|
||||||
"")
|
"")
|
||||||
case "$HOST_ARCH" in
|
case "$HOST_ARCH" in
|
||||||
x86_64) printf '%s\n' x64 ;;
|
x86_64) printf '%s\n' x64 ;;
|
||||||
@@ -697,7 +921,7 @@ select_targets() {
|
|||||||
esac
|
esac
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Unknown --arch '${ARCH_OVERRIDE}'. Use x64|arm64|all." >&2
|
echo "Unknown --arch '${ARCH_OVERRIDE}'. Use x64|arm64|riscv64|ppc64le|loong64|all." >&2
|
||||||
return 1
|
return 1
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
@@ -714,6 +938,7 @@ build_one_target() {
|
|||||||
deb_arch="${meta[1]}"
|
deb_arch="${meta[1]}"
|
||||||
|
|
||||||
echo "[*] Building for target: $short (RID=$rid, DEB arch=$deb_arch)"
|
echo "[*] Building for target: $short (RID=$rid, DEB arch=$deb_arch)"
|
||||||
|
prepare_dotnet_sdk "$short" || die "Failed to prepare .NET SDK for $short."
|
||||||
publish_binary "$rid"
|
publish_binary "$rid"
|
||||||
package_binary "$short" "$rid" "$deb_arch"
|
package_binary "$short" "$rid" "$deb_arch"
|
||||||
}
|
}
|
||||||
@@ -744,12 +969,18 @@ main() {
|
|||||||
prepare_workspace
|
prepare_workspace
|
||||||
resolve_version
|
resolve_version
|
||||||
|
|
||||||
|
download_nuget_packages || die "Failed to download prebuilt NuGet packages."
|
||||||
|
|
||||||
mapfile -t targets < <(select_targets)
|
mapfile -t targets < <(select_targets)
|
||||||
|
[[ "${ARCH_OVERRIDE:-}" == "all" ]] && BUILT_ALL=1 || BUILT_ALL=0
|
||||||
|
|
||||||
for arch in "${targets[@]}"; do
|
for arch in "${targets[@]}"; do
|
||||||
build_one_target "$arch"
|
build_one_target "$arch"
|
||||||
done
|
done
|
||||||
|
|
||||||
|
rm -rf "${NUGET_CACHE_DIR:-}" || true
|
||||||
|
rm -rf "${DOTNET_SDK_DIR:-}" || true
|
||||||
|
|
||||||
print_summary
|
print_summary
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,696 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
VERSION_ARG=""
|
|
||||||
WITH_CORE="both"
|
|
||||||
FORCE_NETCORE=0
|
|
||||||
BUILD_FROM=""
|
|
||||||
XRAY_VER="${XRAY_VER:-}"
|
|
||||||
SING_VER="${SING_VER:-}"
|
|
||||||
|
|
||||||
MIN_KERNEL="6.12"
|
|
||||||
PKGROOT="v2rayN-publish"
|
|
||||||
PROJECT_HINT="v2rayN.Desktop/v2rayN.Desktop.csproj"
|
|
||||||
RPM_TOPDIR="${HOME}/rpmbuild"
|
|
||||||
DOTNET_LOONGARCH_VERSION="10.0.111"
|
|
||||||
DOTNET_LOONGARCH_TAG="v10.0.111-loongarch64"
|
|
||||||
DOTNET_LOONGARCH_BASE="https://github.com/loongson/dotnet/releases/download"
|
|
||||||
DOTNET_LOONGARCH_FILE="dotnet-sdk-${DOTNET_LOONGARCH_VERSION}-linux-loongarch64.tar.gz"
|
|
||||||
DOTNET_SDK_URL="${DOTNET_LOONGARCH_BASE}/${DOTNET_LOONGARCH_TAG}/${DOTNET_LOONGARCH_FILE}"
|
|
||||||
|
|
||||||
OS_ID=""
|
|
||||||
OS_NAME=""
|
|
||||||
OS_VERSION_ID=""
|
|
||||||
HOST_ARCH=""
|
|
||||||
SCRIPT_DIR=""
|
|
||||||
PROJECT=""
|
|
||||||
VERSION=""
|
|
||||||
|
|
||||||
declare -a BUILT_RPMS=()
|
|
||||||
|
|
||||||
die() {
|
|
||||||
echo "$*" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
parse_args() {
|
|
||||||
local first_arg="${1:-}"
|
|
||||||
|
|
||||||
if [[ -n "$first_arg" && "$first_arg" != --* ]]; then
|
|
||||||
VERSION_ARG="$first_arg"
|
|
||||||
shift || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
|
||||||
case "$1" in
|
|
||||||
--with-core) WITH_CORE="${2:-both}"; shift 2 ;;
|
|
||||||
--xray-ver) XRAY_VER="${2:-}"; shift 2 ;;
|
|
||||||
--singbox-ver) SING_VER="${2:-}"; shift 2 ;;
|
|
||||||
--netcore) FORCE_NETCORE=1; shift ;;
|
|
||||||
--buildfrom) BUILD_FROM="${2:-}"; shift 2 ;;
|
|
||||||
*)
|
|
||||||
[[ -n "${VERSION_ARG:-}" ]] || VERSION_ARG="$1"
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
if [[ -n "${VERSION_ARG:-}" && -n "${BUILD_FROM:-}" ]]; then
|
|
||||||
die "You cannot specify both an explicit version and --buildfrom at the same time.
|
|
||||||
Provide either a version (e.g. 7.14.0) OR --buildfrom 1|2|3."
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
detect_environment() {
|
|
||||||
local current_kernel=""
|
|
||||||
local lowest=""
|
|
||||||
|
|
||||||
. /etc/os-release
|
|
||||||
|
|
||||||
OS_ID="${ID:-}"
|
|
||||||
OS_NAME="${NAME:-$OS_ID}"
|
|
||||||
OS_VERSION_ID="${VERSION_ID:-}"
|
|
||||||
HOST_ARCH="$(uname -m)"
|
|
||||||
|
|
||||||
case "$OS_ID" in
|
|
||||||
rhel|rocky|almalinux|fedora|centos)
|
|
||||||
echo "Detected supported system: ${OS_NAME:-$OS_ID} ${OS_VERSION_ID:-}"
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
die "Unsupported system: ${OS_NAME:-unknown} (${OS_ID:-unknown}).
|
|
||||||
This script only supports: RHEL / Rocky / AlmaLinux / Fedora / CentOS."
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
case "$HOST_ARCH" in
|
|
||||||
loongarch64) ;;
|
|
||||||
*) die "Only supports loongarch64" ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
current_kernel="$(uname -r)"
|
|
||||||
lowest="$(printf '%s\n%s\n' "$MIN_KERNEL" "$current_kernel" | sort -V | head -n1)"
|
|
||||||
|
|
||||||
[[ "$lowest" == "$MIN_KERNEL" ]] || die "Kernel $current_kernel is below $MIN_KERNEL"
|
|
||||||
echo "[OK] Kernel $current_kernel verified."
|
|
||||||
}
|
|
||||||
|
|
||||||
install_dependencies() {
|
|
||||||
local install_ok=0
|
|
||||||
local tmp_dotnet=""
|
|
||||||
|
|
||||||
if command -v dnf >/dev/null 2>&1; then
|
|
||||||
sudo dnf -y --nogpgcheck install \
|
|
||||||
rpm-build rpmdevtools curl unzip tar jq rsync git python3 \
|
|
||||||
glibc-devel kernel-headers libatomic file ca-certificates libicu \
|
|
||||||
&& install_ok=1
|
|
||||||
|
|
||||||
mkdir -p "$HOME/.dotnet"
|
|
||||||
tmp_dotnet="$(mktemp -d)"
|
|
||||||
curl -fL "$DOTNET_SDK_URL" -o "$tmp_dotnet/$DOTNET_LOONGARCH_FILE"
|
|
||||||
tar -C "$HOME/.dotnet" -xzf "$tmp_dotnet/$DOTNET_LOONGARCH_FILE"
|
|
||||||
rm -rf "$tmp_dotnet"
|
|
||||||
|
|
||||||
export PATH="$HOME/.dotnet:$PATH"
|
|
||||||
export DOTNET_ROOT="$HOME/.dotnet"
|
|
||||||
|
|
||||||
dotnet --info >/dev/null 2>&1 || install_ok=0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "$install_ok" -ne 1 ]]; then
|
|
||||||
echo "Could not auto-install dependencies for '$OS_ID'. Make sure these are available:"
|
|
||||||
echo "dotnet-loongarch SDK, curl, unzip, tar, rsync, git, python3, rpm, rpmdevtools, rpm-build (on Red Hat branch)"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
prepare_workspace() {
|
|
||||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
cd "$SCRIPT_DIR"
|
|
||||||
|
|
||||||
if [[ -f .gitmodules ]]; then
|
|
||||||
git submodule sync --recursive || true
|
|
||||||
git submodule update --init --recursive || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
PROJECT="$PROJECT_HINT"
|
|
||||||
[[ -f "$PROJECT" ]] || PROJECT="$(find . -maxdepth 3 -name 'v2rayN.Desktop.csproj' | head -n1 || true)"
|
|
||||||
[[ -f "$PROJECT" ]] || die "v2rayN.Desktop.csproj not found"
|
|
||||||
}
|
|
||||||
|
|
||||||
choose_channel() {
|
|
||||||
local ch="latest"
|
|
||||||
local sel=""
|
|
||||||
|
|
||||||
if [[ -n "${BUILD_FROM:-}" ]]; then
|
|
||||||
case "$BUILD_FROM" in
|
|
||||||
1) echo "latest"; return 0 ;;
|
|
||||||
2) echo "prerelease"; return 0 ;;
|
|
||||||
3) echo "keep"; return 0 ;;
|
|
||||||
*) die "[ERROR] Invalid --buildfrom value: ${BUILD_FROM}. Use 1|2|3." ;;
|
|
||||||
esac
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -t 0 ]]; then
|
|
||||||
echo "[?] Choose v2rayN release channel:" >&2
|
|
||||||
echo " 1) Latest (stable) [default]" >&2
|
|
||||||
echo " 2) Pre-release (preview)" >&2
|
|
||||||
echo " 3) Keep current (do nothing)" >&2
|
|
||||||
printf "Enter 1, 2 or 3 [default 1]: " >&2
|
|
||||||
|
|
||||||
if read -r sel </dev/tty; then
|
|
||||||
case "${sel:-}" in
|
|
||||||
2) ch="prerelease" ;;
|
|
||||||
3) ch="keep" ;;
|
|
||||||
esac
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "$ch"
|
|
||||||
}
|
|
||||||
|
|
||||||
get_latest_tag_latest() {
|
|
||||||
curl -fsSL "https://api.github.com/repos/2dust/v2rayN/releases/latest" \
|
|
||||||
| jq -re '.tag_name' \
|
|
||||||
| sed 's/^v//'
|
|
||||||
}
|
|
||||||
|
|
||||||
get_latest_tag_prerelease() {
|
|
||||||
curl -fsSL "https://api.github.com/repos/2dust/v2rayN/releases?per_page=20" \
|
|
||||||
| jq -re 'first(.[] | select(.prerelease == true) | .tag_name)' \
|
|
||||||
| sed 's/^v//'
|
|
||||||
}
|
|
||||||
|
|
||||||
sync_submodules() {
|
|
||||||
if [[ -f .gitmodules ]]; then
|
|
||||||
git submodule sync --recursive || true
|
|
||||||
git submodule update --init --recursive || true
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
git_try_checkout() {
|
|
||||||
local want="$1"
|
|
||||||
local ref=""
|
|
||||||
|
|
||||||
if git rev-parse --git-dir >/dev/null 2>&1; then
|
|
||||||
git fetch --tags --force --prune --depth=1 || true
|
|
||||||
git rev-parse "refs/tags/${want}" >/dev/null 2>&1 && ref="$want"
|
|
||||||
|
|
||||||
if [[ -n "$ref" ]]; then
|
|
||||||
echo "[OK] Found ref '${ref}', checking out..."
|
|
||||||
git checkout -f "$ref"
|
|
||||||
sync_submodules
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
apply_channel_or_keep() {
|
|
||||||
local ch="$1"
|
|
||||||
local tag=""
|
|
||||||
|
|
||||||
if [[ "$ch" == "keep" ]]; then
|
|
||||||
echo "[*] Keep current repository state (no checkout)."
|
|
||||||
VERSION="$(git describe --tags --abbrev=0 2>/dev/null || echo '0.0.0+git')"
|
|
||||||
VERSION="${VERSION#v}"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "[*] Resolving ${ch} tag from GitHub releases..."
|
|
||||||
|
|
||||||
case "$ch" in
|
|
||||||
latest) tag="$(get_latest_tag_latest || true)" ;;
|
|
||||||
prerelease) tag="$(get_latest_tag_prerelease || true)" ;;
|
|
||||||
*) die "Failed to resolve latest tag for channel '${ch}'." ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
[[ -n "$tag" ]] || die "Failed to resolve latest tag for channel '${ch}'."
|
|
||||||
|
|
||||||
echo "[*] Latest tag for '${ch}': ${tag}"
|
|
||||||
git_try_checkout "$tag" || die "Failed to checkout '${tag}'."
|
|
||||||
VERSION="${tag#v}"
|
|
||||||
}
|
|
||||||
|
|
||||||
resolve_version() {
|
|
||||||
if git rev-parse --git-dir >/dev/null 2>&1; then
|
|
||||||
if [[ -n "${VERSION_ARG:-}" ]]; then
|
|
||||||
local clean_ver="${VERSION_ARG#v}"
|
|
||||||
|
|
||||||
if git_try_checkout "$clean_ver"; then
|
|
||||||
VERSION="$clean_ver"
|
|
||||||
else
|
|
||||||
echo "[WARN] Tag '${VERSION_ARG}' not found."
|
|
||||||
apply_channel_or_keep "$(choose_channel)"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
apply_channel_or_keep "$(choose_channel)"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo "Current directory is not a git repo; proceeding on current tree."
|
|
||||||
VERSION="${VERSION_ARG:-0.0.0}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
VERSION="${VERSION#v}"
|
|
||||||
echo "[*] GUI version resolved as: ${VERSION}"
|
|
||||||
}
|
|
||||||
|
|
||||||
xray_url_for_rid() {
|
|
||||||
local rid="$1"
|
|
||||||
local ver="$2"
|
|
||||||
|
|
||||||
case "$rid" in
|
|
||||||
linux-loongarch64) echo "https://github.com/XTLS/Xray-core/releases/download/v${ver}/Xray-linux-loong64.zip" ;;
|
|
||||||
*) return 1 ;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
singbox_url_for_rid() {
|
|
||||||
local rid="$1"
|
|
||||||
local ver="$2"
|
|
||||||
|
|
||||||
case "$rid" in
|
|
||||||
linux-loongarch64) echo "https://github.com/SagerNet/sing-box/releases/download/v${ver}/sing-box-${ver}-linux-loong64.tar.gz" ;;
|
|
||||||
*) return 1 ;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
bundle_url_for_rid() {
|
|
||||||
local rid="$1"
|
|
||||||
|
|
||||||
case "$rid" in
|
|
||||||
linux-loongarch64) echo "https://raw.githubusercontent.com/2dust/v2rayN-core-bin/refs/heads/master/v2rayN-linux-loong64.zip" ;;
|
|
||||||
*) return 1 ;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
download_xray() {
|
|
||||||
local outdir="$1"
|
|
||||||
local rid="$2"
|
|
||||||
local ver="${XRAY_VER:-}"
|
|
||||||
local url=""
|
|
||||||
local tmp=""
|
|
||||||
|
|
||||||
mkdir -p "$outdir"
|
|
||||||
|
|
||||||
if [[ -z "$ver" ]]; then
|
|
||||||
ver="$(curl -fsSL https://api.github.com/repos/XTLS/Xray-core/releases/latest \
|
|
||||||
| grep -Eo '"tag_name":\s*"v[^"]+"' \
|
|
||||||
| sed -E 's/.*"v([^"]+)".*/\1/' \
|
|
||||||
| head -n1)" || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
[[ -n "$ver" ]] || { echo "[xray] Failed to get version"; return 1; }
|
|
||||||
url="$(xray_url_for_rid "$rid" "$ver")" || { echo "[xray] Unsupported RID: $rid"; return 1; }
|
|
||||||
|
|
||||||
echo "[+] Download xray: $url"
|
|
||||||
|
|
||||||
tmp="$(mktemp -d)"
|
|
||||||
curl -fL "$url" -o "$tmp/xray.zip" || { rm -rf "$tmp"; return 1; }
|
|
||||||
unzip -q "$tmp/xray.zip" -d "$tmp" || { rm -rf "$tmp"; return 1; }
|
|
||||||
install -m 755 "$tmp/xray" "$outdir/xray" || { rm -rf "$tmp"; return 1; }
|
|
||||||
rm -rf "$tmp"
|
|
||||||
}
|
|
||||||
|
|
||||||
download_singbox() {
|
|
||||||
local outdir="$1"
|
|
||||||
local rid="$2"
|
|
||||||
local ver="${SING_VER:-}"
|
|
||||||
local url=""
|
|
||||||
local tmp=""
|
|
||||||
local bin=""
|
|
||||||
local cronet=""
|
|
||||||
|
|
||||||
mkdir -p "$outdir"
|
|
||||||
|
|
||||||
if [[ -z "$ver" ]]; then
|
|
||||||
ver="$(curl -fsSL https://api.github.com/repos/SagerNet/sing-box/releases/latest \
|
|
||||||
| grep -Eo '"tag_name":\s*"v[^"]+"' \
|
|
||||||
| sed -E 's/.*"v([^"]+)".*/\1/' \
|
|
||||||
| head -n1)" || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
[[ -n "$ver" ]] || { echo "[sing-box] Failed to get version"; return 1; }
|
|
||||||
url="$(singbox_url_for_rid "$rid" "$ver")" || { echo "[sing-box] Unsupported RID: $rid"; return 1; }
|
|
||||||
|
|
||||||
echo "[+] Download sing-box: $url"
|
|
||||||
|
|
||||||
tmp="$(mktemp -d)"
|
|
||||||
curl -fL "$url" -o "$tmp/singbox.tar.gz" || { rm -rf "$tmp"; return 1; }
|
|
||||||
tar -C "$tmp" -xzf "$tmp/singbox.tar.gz" || { rm -rf "$tmp"; return 1; }
|
|
||||||
|
|
||||||
bin="$(find "$tmp" -type f -name 'sing-box' | head -n1 || true)"
|
|
||||||
[[ -n "$bin" ]] || { echo "[!] sing-box unpack failed"; rm -rf "$tmp"; return 1; }
|
|
||||||
|
|
||||||
install -m 755 "$bin" "$outdir/sing-box" || { rm -rf "$tmp"; return 1; }
|
|
||||||
|
|
||||||
cronet="$(find "$tmp" -type f -name 'libcronet*.so*' | head -n1 || true)"
|
|
||||||
[[ -n "$cronet" ]] && install -m 644 "$cronet" "$outdir/libcronet.so" || true
|
|
||||||
|
|
||||||
rm -rf "$tmp"
|
|
||||||
}
|
|
||||||
|
|
||||||
unify_geo_layout() {
|
|
||||||
local outroot="$1"
|
|
||||||
local n
|
|
||||||
local names=(
|
|
||||||
geosite.dat
|
|
||||||
geoip.dat
|
|
||||||
geoip-only-cn-private.dat
|
|
||||||
Country.mmdb
|
|
||||||
geoip.metadb
|
|
||||||
)
|
|
||||||
|
|
||||||
mkdir -p "$outroot/bin"
|
|
||||||
|
|
||||||
for n in "${names[@]}"; do
|
|
||||||
if [[ -f "$outroot/bin/xray/$n" ]]; then
|
|
||||||
mv -f "$outroot/bin/xray/$n" "$outroot/bin/$n"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
}
|
|
||||||
|
|
||||||
download_geo_assets() {
|
|
||||||
local outroot="$1"
|
|
||||||
local bin_dir="$outroot/bin"
|
|
||||||
local srss_dir="$bin_dir/srss"
|
|
||||||
local f=""
|
|
||||||
|
|
||||||
mkdir -p "$bin_dir" "$srss_dir"
|
|
||||||
|
|
||||||
echo "[+] Download Xray Geo to ${bin_dir}"
|
|
||||||
curl -fsSL -o "$bin_dir/geosite.dat" "https://github.com/Loyalsoldier/V2ray-rules-dat/releases/latest/download/geosite.dat"
|
|
||||||
curl -fsSL -o "$bin_dir/geoip.dat" "https://github.com/Loyalsoldier/V2ray-rules-dat/releases/latest/download/geoip.dat"
|
|
||||||
curl -fsSL -o "$bin_dir/geoip-only-cn-private.dat" "https://raw.githubusercontent.com/Loyalsoldier/geoip/release/geoip-only-cn-private.dat"
|
|
||||||
curl -fsSL -o "$bin_dir/Country.mmdb" "https://raw.githubusercontent.com/Loyalsoldier/geoip/release/Country.mmdb"
|
|
||||||
|
|
||||||
echo "[+] Download sing-box rule DB & rule-sets"
|
|
||||||
curl -fsSL -o "$bin_dir/geoip.metadb" "https://github.com/MetaCubeX/meta-rules-dat/releases/latest/download/geoip.metadb"
|
|
||||||
|
|
||||||
for f in geoip-private.srs geoip-cn.srs geoip-facebook.srs geoip-fastly.srs geoip-google.srs geoip-netflix.srs geoip-telegram.srs geoip-twitter.srs; do
|
|
||||||
curl -fsSL -o "$srss_dir/$f" "https://raw.githubusercontent.com/2dust/sing-box-rules/refs/heads/rule-set-geoip/$f"
|
|
||||||
done
|
|
||||||
|
|
||||||
for f in geosite-cn.srs geosite-gfw.srs geosite-google.srs geosite-greatfire.srs geosite-geolocation-cn.srs geosite-category-ads-all.srs geosite-private.srs; do
|
|
||||||
curl -fsSL -o "$srss_dir/$f" "https://raw.githubusercontent.com/2dust/sing-box-rules/refs/heads/rule-set-geosite/$f"
|
|
||||||
done
|
|
||||||
|
|
||||||
unify_geo_layout "$outroot"
|
|
||||||
}
|
|
||||||
|
|
||||||
populate_assets_zip_mode() {
|
|
||||||
local outroot="$1"
|
|
||||||
local rid="$2"
|
|
||||||
local url=""
|
|
||||||
local tmp=""
|
|
||||||
local nested_dir=""
|
|
||||||
|
|
||||||
url="$(bundle_url_for_rid "$rid")" || { echo "[!] Bundle unsupported RID: $rid"; return 1; }
|
|
||||||
|
|
||||||
echo "[+] Try v2rayN bundle archive: $url"
|
|
||||||
|
|
||||||
tmp="$(mktemp -d)"
|
|
||||||
curl -fL "$url" -o "$tmp/v2rayn.zip" || { echo "[!] Bundle download failed"; rm -rf "$tmp"; return 1; }
|
|
||||||
unzip -q "$tmp/v2rayn.zip" -d "$tmp" || { echo "[!] Bundle unzip failed"; rm -rf "$tmp"; return 1; }
|
|
||||||
|
|
||||||
if [[ -d "$tmp/bin" ]]; then
|
|
||||||
mkdir -p "$outroot/bin"
|
|
||||||
rsync -a "$tmp/bin/" "$outroot/bin/"
|
|
||||||
else
|
|
||||||
rsync -a "$tmp/" "$outroot/"
|
|
||||||
fi
|
|
||||||
|
|
||||||
rm -f "$outroot/v2rayn.zip" 2>/dev/null || true
|
|
||||||
find "$outroot" -type d -name "mihomo" -prune -exec rm -rf {} + 2>/dev/null || true
|
|
||||||
|
|
||||||
nested_dir="$(find "$outroot" -maxdepth 1 -type d -name 'v2rayN-linux-*' | head -n1 || true)"
|
|
||||||
if [[ -n "$nested_dir" && -d "$nested_dir/bin" ]]; then
|
|
||||||
mkdir -p "$outroot/bin"
|
|
||||||
rsync -a "$nested_dir/bin/" "$outroot/bin/"
|
|
||||||
rm -rf "$nested_dir"
|
|
||||||
fi
|
|
||||||
|
|
||||||
unify_geo_layout "$outroot"
|
|
||||||
rm -rf "$tmp"
|
|
||||||
|
|
||||||
echo "[+] Bundle extracted to $outroot"
|
|
||||||
}
|
|
||||||
|
|
||||||
populate_assets_netcore_mode() {
|
|
||||||
local outroot="$1"
|
|
||||||
local rid="$2"
|
|
||||||
|
|
||||||
mkdir -p "$outroot/bin/xray" "$outroot/bin/sing_box"
|
|
||||||
|
|
||||||
if [[ "$WITH_CORE" == "xray" || "$WITH_CORE" == "both" ]]; then
|
|
||||||
download_xray "$outroot/bin/xray" "$rid" || echo "[!] xray download failed (skipped)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "$WITH_CORE" == "sing-box" || "$WITH_CORE" == "both" ]]; then
|
|
||||||
download_singbox "$outroot/bin/sing_box" "$rid" || echo "[!] sing-box download failed (skipped)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
download_geo_assets "$outroot" || echo "[!] Geo rules download failed (skipped)"
|
|
||||||
}
|
|
||||||
|
|
||||||
stage_runtime_assets() {
|
|
||||||
local outroot="$1"
|
|
||||||
local rid="$2"
|
|
||||||
|
|
||||||
mkdir -p "$outroot/bin/xray" "$outroot/bin/sing_box"
|
|
||||||
|
|
||||||
if [[ "$FORCE_NETCORE" -eq 0 ]]; then
|
|
||||||
if populate_assets_zip_mode "$outroot" "$rid"; then
|
|
||||||
echo "[*] Using v2rayN bundle archive."
|
|
||||||
else
|
|
||||||
echo "[*] Bundle failed, fallback to separate core + rules."
|
|
||||||
populate_assets_netcore_mode "$outroot" "$rid"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo "[*] --netcore specified: use separate core + rules."
|
|
||||||
populate_assets_netcore_mode "$outroot" "$rid"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
describe_target() {
|
|
||||||
local short="$1"
|
|
||||||
|
|
||||||
case "$short" in
|
|
||||||
loongarch64) printf '%s\n%s\n%s\n' "linux-loongarch64" "loongarch64" "loongarch64" ;;
|
|
||||||
*) echo "Unknown arch '$short' (use loongarch64)" >&2; return 1 ;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
publish_binary() {
|
|
||||||
local rid="$1"
|
|
||||||
|
|
||||||
dotnet clean "$PROJECT" -c Release
|
|
||||||
rm -rf "$(dirname "$PROJECT")/bin/Release/net10.0" || true
|
|
||||||
dotnet restore "$PROJECT"
|
|
||||||
dotnet publish "$PROJECT" -c Release -r "$rid" -p:PublishSingleFile=false -p:SelfContained=true
|
|
||||||
}
|
|
||||||
|
|
||||||
write_spec_file() {
|
|
||||||
local specfile="$1"
|
|
||||||
|
|
||||||
cat > "$specfile" <<'SPEC'
|
|
||||||
%global debug_package %{nil}
|
|
||||||
%undefine _debuginfo_subpackages
|
|
||||||
%undefine _debugsource_packages
|
|
||||||
%global __requires_exclude ^liblttng-ust\.so\..*$
|
|
||||||
|
|
||||||
Name: v2rayN
|
|
||||||
Version: __VERSION__
|
|
||||||
Release: 1%{?dist}
|
|
||||||
Summary: v2rayN (Avalonia) GUI client for Linux
|
|
||||||
License: GPL-3.0-only
|
|
||||||
URL: https://github.com/2dust/v2rayN
|
|
||||||
BugURL: https://github.com/2dust/v2rayN/issues
|
|
||||||
ExclusiveArch: loongarch64
|
|
||||||
Source0: __PKGROOT__.tar.gz
|
|
||||||
|
|
||||||
Requires: cairo, pango, openssl, mesa-libEGL, mesa-libGL
|
|
||||||
Requires: glibc >= 2.39
|
|
||||||
Requires: fontconfig >= 2.15.0
|
|
||||||
Requires: desktop-file-utils >= 0.26
|
|
||||||
Requires: xdg-utils >= 1.1.3
|
|
||||||
Requires: coreutils >= 9.4
|
|
||||||
Requires: bash >= 5.2.21
|
|
||||||
Requires: freetype >= 2.13
|
|
||||||
|
|
||||||
%description
|
|
||||||
v2rayN Linux for Red Hat Enterprise Linux
|
|
||||||
Support vless / vmess / Trojan / http / socks / Anytls / Hysteria2 / Shadowsocks / tuic / WireGuard
|
|
||||||
Support Red Hat Enterprise Linux / Fedora Linux / Rocky Linux / AlmaLinux / CentOS
|
|
||||||
For more information, Please visit our website
|
|
||||||
https://github.com/2dust/v2rayN
|
|
||||||
|
|
||||||
%prep
|
|
||||||
%setup -q -n __PKGROOT__
|
|
||||||
|
|
||||||
%build
|
|
||||||
|
|
||||||
%install
|
|
||||||
install -dm0755 %{buildroot}/opt/v2rayN
|
|
||||||
cp -a * %{buildroot}/opt/v2rayN/
|
|
||||||
|
|
||||||
find %{buildroot}/opt/v2rayN -type d -exec chmod 0755 {} +
|
|
||||||
find %{buildroot}/opt/v2rayN -type f -exec chmod 0644 {} +
|
|
||||||
[ -f %{buildroot}/opt/v2rayN/v2rayN ] && chmod 0755 %{buildroot}/opt/v2rayN/v2rayN || :
|
|
||||||
|
|
||||||
install -dm0755 %{buildroot}%{_bindir}
|
|
||||||
install -m0755 /dev/stdin %{buildroot}%{_bindir}/v2rayn << 'EOF'
|
|
||||||
#!/usr/bin/bash
|
|
||||||
set -euo pipefail
|
|
||||||
DIR="/opt/v2rayN"
|
|
||||||
|
|
||||||
if [[ -x "$DIR/v2rayN" ]]; then exec "$DIR/v2rayN" "$@"; fi
|
|
||||||
|
|
||||||
for dll in v2rayN.Desktop.dll v2rayN.dll; do
|
|
||||||
if [[ -f "$DIR/$dll" ]]; then exec /usr/bin/dotnet "$DIR/$dll" "$@"; fi
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "v2rayN launcher: no executable found in $DIR" >&2
|
|
||||||
ls -l "$DIR" >&2 || true
|
|
||||||
exit 1
|
|
||||||
EOF
|
|
||||||
|
|
||||||
install -dm0755 %{buildroot}%{_datadir}/applications
|
|
||||||
install -m0644 /dev/stdin %{buildroot}%{_datadir}/applications/v2rayn.desktop << 'EOF'
|
|
||||||
[Desktop Entry]
|
|
||||||
Type=Application
|
|
||||||
Name=v2rayN
|
|
||||||
Comment=v2rayN for Red Hat Enterprise Linux
|
|
||||||
Exec=v2rayn
|
|
||||||
Icon=v2rayn
|
|
||||||
Terminal=false
|
|
||||||
Categories=Network;
|
|
||||||
EOF
|
|
||||||
|
|
||||||
install -dm0755 %{buildroot}%{_datadir}/icons/hicolor/256x256/apps
|
|
||||||
install -m0644 %{_builddir}/__PKGROOT__/v2rayn.png %{buildroot}%{_datadir}/icons/hicolor/256x256/apps/v2rayn.png
|
|
||||||
|
|
||||||
%post
|
|
||||||
/usr/bin/update-desktop-database %{_datadir}/applications >/dev/null 2>&1 || true
|
|
||||||
/usr/bin/gtk-update-icon-cache -f %{_datadir}/icons/hicolor >/dev/null 2>&1 || true
|
|
||||||
|
|
||||||
%postun
|
|
||||||
/usr/bin/update-desktop-database %{_datadir}/applications >/dev/null 2>&1 || true
|
|
||||||
/usr/bin/gtk-update-icon-cache -f %{_datadir}/icons/hicolor >/dev/null 2>&1 || true
|
|
||||||
|
|
||||||
%files
|
|
||||||
%{_bindir}/v2rayn
|
|
||||||
/opt/v2rayN
|
|
||||||
%{_datadir}/applications/v2rayn.desktop
|
|
||||||
%{_datadir}/icons/hicolor/256x256/apps/v2rayn.png
|
|
||||||
SPEC
|
|
||||||
|
|
||||||
sed -i "s/__VERSION__/${VERSION}/g" "$specfile"
|
|
||||||
sed -i "s/__PKGROOT__/${PKGROOT}/g" "$specfile"
|
|
||||||
}
|
|
||||||
|
|
||||||
package_binary() {
|
|
||||||
local short="$1"
|
|
||||||
local rid="$2"
|
|
||||||
local rpm_target="$3"
|
|
||||||
local archdir="$4"
|
|
||||||
local pubdir=""
|
|
||||||
local workdir=""
|
|
||||||
local specfile=""
|
|
||||||
local sourcedir=""
|
|
||||||
local specdir=""
|
|
||||||
local project_dir=""
|
|
||||||
local icon_candidate=""
|
|
||||||
local f=""
|
|
||||||
|
|
||||||
pubdir="$(dirname "$PROJECT")/bin/Release/net10.0/${rid}/publish"
|
|
||||||
[[ -d "$pubdir" ]] || { echo "Publish directory not found: $pubdir"; return 1; }
|
|
||||||
|
|
||||||
workdir="$(mktemp -d)"
|
|
||||||
trap '[[ -n "${workdir:-}" ]] && rm -rf "$workdir"' RETURN
|
|
||||||
|
|
||||||
mkdir -p "$workdir/$PKGROOT"
|
|
||||||
cp -a "$pubdir/." "$workdir/$PKGROOT/"
|
|
||||||
|
|
||||||
project_dir="$(cd "$(dirname "$PROJECT")" && pwd)"
|
|
||||||
icon_candidate="$project_dir/v2rayN.png"
|
|
||||||
[[ -f "$icon_candidate" ]] || { echo "Required icon not found: $icon_candidate"; return 1; }
|
|
||||||
cp "$icon_candidate" "$workdir/$PKGROOT/v2rayn.png"
|
|
||||||
|
|
||||||
stage_runtime_assets "$workdir/$PKGROOT" "$rid"
|
|
||||||
|
|
||||||
rpmdev-setuptree
|
|
||||||
sourcedir="${RPM_TOPDIR}/SOURCES"
|
|
||||||
specdir="${RPM_TOPDIR}/SPECS"
|
|
||||||
specfile="${specdir}/v2rayN.spec"
|
|
||||||
|
|
||||||
mkdir -p "$sourcedir" "$specdir"
|
|
||||||
tar -C "$workdir" -czf "$sourcedir/$PKGROOT.tar.gz" "$PKGROOT"
|
|
||||||
|
|
||||||
write_spec_file "$specfile"
|
|
||||||
rpmbuild -ba "$specfile" --target "$rpm_target"
|
|
||||||
|
|
||||||
echo "Build done for $short. RPM at:"
|
|
||||||
for f in "${RPM_TOPDIR}/RPMS/${archdir}/v2rayN-${VERSION}-1"*.rpm; do
|
|
||||||
[[ -e "$f" ]] || continue
|
|
||||||
echo " $f"
|
|
||||||
BUILT_RPMS+=("$f")
|
|
||||||
done
|
|
||||||
}
|
|
||||||
|
|
||||||
select_targets() {
|
|
||||||
printf '%s\n' loongarch64
|
|
||||||
}
|
|
||||||
|
|
||||||
build_one_target() {
|
|
||||||
local short="$1"
|
|
||||||
local meta=()
|
|
||||||
local rid=""
|
|
||||||
local rpm_target=""
|
|
||||||
local archdir=""
|
|
||||||
|
|
||||||
mapfile -t meta < <(describe_target "$short") || return 1
|
|
||||||
rid="${meta[0]}"
|
|
||||||
rpm_target="${meta[1]}"
|
|
||||||
archdir="${meta[2]}"
|
|
||||||
|
|
||||||
echo "[*] Building for target: $short (RID=$rid, RPM --target $rpm_target)"
|
|
||||||
publish_binary "$rid"
|
|
||||||
package_binary "$short" "$rid" "$rpm_target" "$archdir"
|
|
||||||
}
|
|
||||||
|
|
||||||
print_summary() {
|
|
||||||
local rp=""
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "================ Build Summary ================"
|
|
||||||
if [[ "${#BUILT_RPMS[@]}" -gt 0 ]]; then
|
|
||||||
for rp in "${BUILT_RPMS[@]}"; do
|
|
||||||
echo "$rp"
|
|
||||||
done
|
|
||||||
else
|
|
||||||
echo "No RPMs detected in summary (check build logs above)."
|
|
||||||
fi
|
|
||||||
echo "=============================================="
|
|
||||||
}
|
|
||||||
|
|
||||||
main() {
|
|
||||||
local targets=()
|
|
||||||
local arch=""
|
|
||||||
|
|
||||||
parse_args "$@"
|
|
||||||
detect_environment
|
|
||||||
install_dependencies
|
|
||||||
prepare_workspace
|
|
||||||
resolve_version
|
|
||||||
|
|
||||||
mapfile -t targets < <(select_targets)
|
|
||||||
|
|
||||||
for arch in "${targets[@]}"; do
|
|
||||||
build_one_target "$arch"
|
|
||||||
done
|
|
||||||
|
|
||||||
print_summary
|
|
||||||
}
|
|
||||||
|
|
||||||
main "$@"
|
|
||||||
@@ -8,14 +8,19 @@ BUILD_FROM=""
|
|||||||
XRAY_VER="${XRAY_VER:-}"
|
XRAY_VER="${XRAY_VER:-}"
|
||||||
SING_VER="${SING_VER:-}"
|
SING_VER="${SING_VER:-}"
|
||||||
|
|
||||||
MIN_KERNEL="5.10"
|
MIN_KERNEL="6.12"
|
||||||
PKGROOT="v2rayN-publish"
|
PKGROOT="v2rayN-publish"
|
||||||
PROJECT_HINT="v2rayN.Desktop/v2rayN.Desktop.csproj"
|
PROJECT_HINT="v2rayN.Desktop/v2rayN.Desktop.csproj"
|
||||||
RPM_TOPDIR="${HOME}/rpmbuild"
|
RPM_TOPDIR="${HOME}/rpmbuild"
|
||||||
DOTNET_RISCV_VERSION="10.0.111"
|
|
||||||
DOTNET_RISCV_BASE="https://github.com/xujiegb/dotnet-riscv/releases/download"
|
NUGET_CACHE_DIR=""
|
||||||
DOTNET_RISCV_FILE="dotnet-sdk-${DOTNET_RISCV_VERSION}-linux-riscv64.tar.gz"
|
DOTNET_NUPKG_BASE="${DOTNET_NUPKG_BASE:-https://github.com/autorepobot/dotnet-nupkg-prebuild/releases/download}"
|
||||||
DOTNET_SDK_URL="${DOTNET_RISCV_BASE}/${DOTNET_RISCV_VERSION}/${DOTNET_RISCV_FILE}"
|
SKIASHARP_BASE="${SKIASHARP_BASE:-https://github.com/autorepobot/SkiaSharp-ppc64le/releases/download}"
|
||||||
|
SkiaSharp_VERSION="${SkiaSharp_VERSION:-3.119.4}"
|
||||||
|
HarfBuzzSharp_VERSION="${HarfBuzzSharp_VERSION:-8.3.1.5}"
|
||||||
|
|
||||||
|
DOTNET_SDK_VERSION="${DOTNET_SDK_VERSION:-10.0.114}"
|
||||||
|
DOTNET_X86_VERSION="${DOTNET_X86_VERSION:-10.0.114}"
|
||||||
|
|
||||||
OS_ID=""
|
OS_ID=""
|
||||||
OS_NAME=""
|
OS_NAME=""
|
||||||
@@ -24,6 +29,13 @@ HOST_ARCH=""
|
|||||||
SCRIPT_DIR=""
|
SCRIPT_DIR=""
|
||||||
PROJECT=""
|
PROJECT=""
|
||||||
VERSION=""
|
VERSION=""
|
||||||
|
BUILT_ALL=0
|
||||||
|
|
||||||
|
X86_RID="linux-x86"
|
||||||
|
X86_RPM_ARCH="i686"
|
||||||
|
|
||||||
|
RPM_STRIP=""
|
||||||
|
RPM_OBJDUMP=""
|
||||||
|
|
||||||
declare -a BUILT_RPMS=()
|
declare -a BUILT_RPMS=()
|
||||||
|
|
||||||
@@ -32,6 +44,13 @@ die() {
|
|||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get_runtime_version() {
|
||||||
|
local sdk_ver="$1"
|
||||||
|
local prefix="${sdk_ver%.*}"
|
||||||
|
local patch="${sdk_ver##*.}"
|
||||||
|
echo "${prefix}.${patch#1}"
|
||||||
|
}
|
||||||
|
|
||||||
parse_args() {
|
parse_args() {
|
||||||
local first_arg="${1:-}"
|
local first_arg="${1:-}"
|
||||||
|
|
||||||
@@ -82,8 +101,8 @@ This script only supports: RHEL / Rocky / AlmaLinux / Fedora / CentOS."
|
|||||||
esac
|
esac
|
||||||
|
|
||||||
case "$HOST_ARCH" in
|
case "$HOST_ARCH" in
|
||||||
riscv64) ;;
|
x86_64|aarch64) ;;
|
||||||
*) die "Only supports riscv64" ;;
|
*) die "This script cross-builds linux-x86/i686 from x86_64 or aarch64 only." ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
current_kernel="$(uname -r)"
|
current_kernel="$(uname -r)"
|
||||||
@@ -91,37 +110,68 @@ This script only supports: RHEL / Rocky / AlmaLinux / Fedora / CentOS."
|
|||||||
|
|
||||||
[[ "$lowest" == "$MIN_KERNEL" ]] || die "Kernel $current_kernel is below $MIN_KERNEL"
|
[[ "$lowest" == "$MIN_KERNEL" ]] || die "Kernel $current_kernel is below $MIN_KERNEL"
|
||||||
echo "[OK] Kernel $current_kernel verified."
|
echo "[OK] Kernel $current_kernel verified."
|
||||||
|
echo "[OK] Host architecture: $HOST_ARCH"
|
||||||
|
echo "[OK] Target: $X86_RID (RPM $X86_RPM_ARCH)"
|
||||||
}
|
}
|
||||||
|
|
||||||
install_dependencies() {
|
install_dependencies() {
|
||||||
local install_ok=0
|
local install_ok=0
|
||||||
local tmp_dotnet=""
|
|
||||||
|
|
||||||
if command -v dnf >/dev/null 2>&1; then
|
if command -v dnf >/dev/null 2>&1; then
|
||||||
sudo dnf -y install \
|
sudo dnf -y install \
|
||||||
rpm-build rpmdevtools curl unzip tar jq rsync git python3 \
|
rpm-build rpmdevtools curl unzip tar jq rsync \
|
||||||
glibc-devel kernel-headers libatomic file ca-certificates libicu \
|
python3 binutils wget libicu \
|
||||||
&& install_ok=1
|
&& install_ok=1
|
||||||
|
|
||||||
mkdir -p "$HOME/.dotnet"
|
case "$HOST_ARCH" in
|
||||||
tmp_dotnet="$(mktemp -d)"
|
x86_64|aarch64)
|
||||||
curl -fL "$DOTNET_SDK_URL" -o "$tmp_dotnet/$DOTNET_RISCV_FILE"
|
wget -q https://dot.net/v1/dotnet-install.sh
|
||||||
tar -C "$HOME/.dotnet" -xzf "$tmp_dotnet/$DOTNET_RISCV_FILE"
|
chmod +x dotnet-install.sh
|
||||||
rm -rf "$tmp_dotnet"
|
./dotnet-install.sh --version "$DOTNET_SDK_VERSION" --install-dir "$HOME/.dotnet"
|
||||||
|
|
||||||
export PATH="$HOME/.dotnet:$PATH"
|
export PATH="$HOME/.dotnet:$PATH"
|
||||||
export DOTNET_ROOT="$HOME/.dotnet"
|
export DOTNET_ROOT="$HOME/.dotnet"
|
||||||
|
|
||||||
dotnet --info >/dev/null 2>&1 || install_ok=0
|
dotnet --info >/dev/null 2>&1 && install_ok=1
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
die "This script cross-builds linux-x86/i686 from x86_64 or aarch64 only."
|
||||||
|
;;
|
||||||
|
esac
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$install_ok" -ne 1 ]]; then
|
if [[ "$install_ok" -ne 1 ]]; then
|
||||||
echo "Could not auto-install dependencies for '$OS_ID'. Make sure these are available:"
|
echo "Could not auto-install dependencies for '$OS_ID'. Make sure these are available:"
|
||||||
echo "dotnet-riscv SDK, curl, unzip, tar, rsync, git, python3, rpm, rpmdevtools, rpm-build (on Red Hat branch)"
|
echo "dotnet 10.x, python3, rpm, rpmdevtools, rpm-build, binutils"
|
||||||
|
echo "curl, unzip, tar, rsync, jq, wget"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
prepare_cross_rpm_tools() {
|
||||||
|
local target="$1"
|
||||||
|
|
||||||
|
case "$HOST_ARCH:$target" in
|
||||||
|
x86_64:i686|aarch64:i686)
|
||||||
|
echo "[+] Prepare host RPM binutils for $target"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
RPM_STRIP=""
|
||||||
|
RPM_OBJDUMP=""
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
command -v strip >/dev/null 2>&1 || die "strip tool not found"
|
||||||
|
command -v objdump >/dev/null 2>&1 || die "objdump tool not found"
|
||||||
|
|
||||||
|
RPM_STRIP="$(command -v strip)"
|
||||||
|
RPM_OBJDUMP="$(command -v objdump)"
|
||||||
|
|
||||||
|
echo "[OK] RPM strip: $RPM_STRIP"
|
||||||
|
echo "[OK] RPM objdump: $RPM_OBJDUMP"
|
||||||
|
}
|
||||||
|
|
||||||
prepare_workspace() {
|
prepare_workspace() {
|
||||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
cd "$SCRIPT_DIR"
|
cd "$SCRIPT_DIR"
|
||||||
@@ -254,13 +304,156 @@ resolve_version() {
|
|||||||
echo "[*] GUI version resolved as: ${VERSION}"
|
echo "[*] GUI version resolved as: ${VERSION}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
download_nuget_packages() {
|
||||||
|
NUGET_CACHE_DIR="$(mktemp -d)"
|
||||||
|
|
||||||
|
local x86_rt
|
||||||
|
x86_rt="$(get_runtime_version "$DOTNET_X86_VERSION")"
|
||||||
|
|
||||||
|
local urls=(
|
||||||
|
"${DOTNET_NUPKG_BASE}/${x86_rt}/Microsoft.AspNetCore.App.Runtime.linux-x86.${x86_rt}.nupkg"
|
||||||
|
"${DOTNET_NUPKG_BASE}/${x86_rt}/Microsoft.NETCore.App.Host.linux-x86.${x86_rt}.nupkg"
|
||||||
|
"${DOTNET_NUPKG_BASE}/${x86_rt}/Microsoft.NETCore.App.Runtime.linux-x86.${x86_rt}.nupkg"
|
||||||
|
"${SKIASHARP_BASE}/v${SkiaSharp_VERSION}/SkiaSharp.NativeAssets.Linux.${SkiaSharp_VERSION}.nupkg"
|
||||||
|
"${SKIASHARP_BASE}/v${SkiaSharp_VERSION}/HarfBuzzSharp.NativeAssets.Linux.${HarfBuzzSharp_VERSION}.nupkg"
|
||||||
|
)
|
||||||
|
|
||||||
|
echo "[+] Downloading prebuilt NuGet packages to: $NUGET_CACHE_DIR"
|
||||||
|
local u pkg_name
|
||||||
|
for u in "${urls[@]}"; do
|
||||||
|
pkg_name="${u##*/}"
|
||||||
|
echo " -> $pkg_name"
|
||||||
|
curl -fL "$u" -o "$NUGET_CACHE_DIR/$pkg_name" || {
|
||||||
|
echo "[!] Failed to download $u" >&2
|
||||||
|
rm -rf "$NUGET_CACHE_DIR"
|
||||||
|
NUGET_CACHE_DIR=""
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "[OK] Prebuilt NuGet packages ready in $NUGET_CACHE_DIR"
|
||||||
|
}
|
||||||
|
|
||||||
|
prepare_dotnet_sdk() {
|
||||||
|
echo "[+] Using host .NET SDK for cross-target $X86_RID"
|
||||||
|
|
||||||
|
dotnet --info
|
||||||
|
|
||||||
|
if [[ -z "${DOTNET_ROOT:-}" ]]; then
|
||||||
|
local dotnet_bin=""
|
||||||
|
dotnet_bin="$(readlink -f "$(command -v dotnet)")"
|
||||||
|
DOTNET_ROOT="$(dirname "$dotnet_bin")"
|
||||||
|
export DOTNET_ROOT
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[OK] DOTNET_ROOT: $DOTNET_ROOT"
|
||||||
|
}
|
||||||
|
|
||||||
|
patch_x86_sdk() {
|
||||||
|
local sdk_props=""
|
||||||
|
local sdk_version=""
|
||||||
|
local dotnet_root=""
|
||||||
|
|
||||||
|
sdk_version="$(dotnet --version)"
|
||||||
|
|
||||||
|
dotnet_root="${DOTNET_ROOT:-}"
|
||||||
|
if [[ -z "$dotnet_root" ]]; then
|
||||||
|
local dotnet_bin=""
|
||||||
|
dotnet_bin="$(readlink -f "$(command -v dotnet)")"
|
||||||
|
dotnet_root="$(dirname "$dotnet_bin")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
sdk_props="$dotnet_root/sdk/${sdk_version}/Microsoft.NETCoreSdk.BundledVersions.props"
|
||||||
|
|
||||||
|
[[ -f "$sdk_props" ]] || die "SDK props file not found: $sdk_props"
|
||||||
|
|
||||||
|
echo "[+] Patching SDK: $sdk_props"
|
||||||
|
|
||||||
|
python3 - "$sdk_props" <<'PY'
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
path = Path(sys.argv[1])
|
||||||
|
text = path.read_text()
|
||||||
|
old = text
|
||||||
|
|
||||||
|
|
||||||
|
def add_attr_to_known_blocks(text, marker, attr):
|
||||||
|
start = 0
|
||||||
|
while True:
|
||||||
|
start = text.find(marker, start)
|
||||||
|
if start == -1:
|
||||||
|
break
|
||||||
|
|
||||||
|
end = text.find('/>', start)
|
||||||
|
if end == -1:
|
||||||
|
break
|
||||||
|
|
||||||
|
block = text[start:end]
|
||||||
|
attr_pos = block.find(attr)
|
||||||
|
|
||||||
|
if attr_pos != -1:
|
||||||
|
value_start = attr_pos + len(attr)
|
||||||
|
value_end = block.find('"', value_start)
|
||||||
|
|
||||||
|
if value_end != -1:
|
||||||
|
value = block[value_start:value_end]
|
||||||
|
values = value.split(';')
|
||||||
|
|
||||||
|
if 'linux-x86' not in values:
|
||||||
|
value = value + ';linux-x86'
|
||||||
|
block = (
|
||||||
|
block[:value_start]
|
||||||
|
+ value
|
||||||
|
+ block[value_end:]
|
||||||
|
)
|
||||||
|
text = text[:start] + block + text[end:]
|
||||||
|
end = start + len(block)
|
||||||
|
|
||||||
|
start = end + 2
|
||||||
|
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
for framework in ('Microsoft.NETCore.App', 'Microsoft.AspNetCore.App'):
|
||||||
|
text = add_attr_to_known_blocks(
|
||||||
|
text,
|
||||||
|
f'<KnownFrameworkReference Include="{framework}"',
|
||||||
|
'RuntimePackRuntimeIdentifiers="',
|
||||||
|
)
|
||||||
|
|
||||||
|
text = add_attr_to_known_blocks(
|
||||||
|
text,
|
||||||
|
'<KnownAppHostPack',
|
||||||
|
'AppHostRuntimeIdentifiers="',
|
||||||
|
)
|
||||||
|
|
||||||
|
if text == old:
|
||||||
|
raise SystemExit(
|
||||||
|
'ERROR: No SDK RuntimePack/AppHost mapping was patched'
|
||||||
|
)
|
||||||
|
|
||||||
|
path.write_text(text)
|
||||||
|
PY
|
||||||
|
|
||||||
|
echo "===== Verify RuntimePackRuntimeIdentifiers ====="
|
||||||
|
grep -n 'RuntimePackRuntimeIdentifiers=' "$sdk_props" | grep 'linux-x86'
|
||||||
|
|
||||||
|
echo "===== Verify AppHostRuntimeIdentifiers ====="
|
||||||
|
grep -n 'AppHostRuntimeIdentifiers=' "$sdk_props" | grep 'linux-x86'
|
||||||
|
}
|
||||||
|
|
||||||
xray_url_for_rid() {
|
xray_url_for_rid() {
|
||||||
local rid="$1"
|
local rid="$1"
|
||||||
local ver="$2"
|
local ver="$2"
|
||||||
|
|
||||||
case "$rid" in
|
case "$rid" in
|
||||||
linux-riscv64) echo "https://github.com/XTLS/Xray-core/releases/download/v${ver}/Xray-linux-riscv64.zip" ;;
|
linux-x86)
|
||||||
*) return 1 ;;
|
echo "https://github.com/XTLS/Xray-core/releases/download/v${ver}/Xray-linux-32.zip"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,8 +462,12 @@ singbox_url_for_rid() {
|
|||||||
local ver="$2"
|
local ver="$2"
|
||||||
|
|
||||||
case "$rid" in
|
case "$rid" in
|
||||||
linux-riscv64) echo "https://github.com/SagerNet/sing-box/releases/download/v${ver}/sing-box-${ver}-linux-riscv64.tar.gz" ;;
|
linux-x86)
|
||||||
*) return 1 ;;
|
echo "https://github.com/SagerNet/sing-box/releases/download/v${ver}/sing-box-${ver}-linux-386.tar.gz"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,8 +475,12 @@ bundle_url_for_rid() {
|
|||||||
local rid="$1"
|
local rid="$1"
|
||||||
|
|
||||||
case "$rid" in
|
case "$rid" in
|
||||||
linux-riscv64) echo "https://raw.githubusercontent.com/2dust/v2rayN-core-bin/refs/heads/master/v2rayN-linux-riscv64.zip" ;;
|
linux-x86)
|
||||||
*) return 1 ;;
|
echo "https://raw.githubusercontent.com/2dust/v2rayN-core-bin/refs/heads/master/v2rayN-linux-86.zip"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,15 +500,34 @@ download_xray() {
|
|||||||
| head -n1)" || true
|
| head -n1)" || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
[[ -n "$ver" ]] || { echo "[xray] Failed to get version"; return 1; }
|
[[ -n "$ver" ]] || {
|
||||||
url="$(xray_url_for_rid "$rid" "$ver")" || { echo "[xray] Unsupported RID: $rid"; return 1; }
|
echo "[xray] Failed to get version"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
url="$(xray_url_for_rid "$rid" "$ver")" || {
|
||||||
|
echo "[xray] Unsupported RID for upstream Xray: $rid (skipped)"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
echo "[+] Download xray: $url"
|
echo "[+] Download xray: $url"
|
||||||
|
|
||||||
tmp="$(mktemp -d)"
|
tmp="$(mktemp -d)"
|
||||||
curl -fL "$url" -o "$tmp/xray.zip" || { rm -rf "$tmp"; return 1; }
|
curl -fL "$url" -o "$tmp/xray.zip" || {
|
||||||
unzip -q "$tmp/xray.zip" -d "$tmp" || { rm -rf "$tmp"; return 1; }
|
rm -rf "$tmp"
|
||||||
install -m 755 "$tmp/xray" "$outdir/xray" || { rm -rf "$tmp"; return 1; }
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
unzip -q "$tmp/xray.zip" -d "$tmp" || {
|
||||||
|
rm -rf "$tmp"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
install -m 755 "$tmp/xray" "$outdir/xray" || {
|
||||||
|
rm -rf "$tmp"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
rm -rf "$tmp"
|
rm -rf "$tmp"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -329,19 +549,40 @@ download_singbox() {
|
|||||||
| head -n1)" || true
|
| head -n1)" || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
[[ -n "$ver" ]] || { echo "[sing-box] Failed to get version"; return 1; }
|
[[ -n "$ver" ]] || {
|
||||||
url="$(singbox_url_for_rid "$rid" "$ver")" || { echo "[sing-box] Unsupported RID: $rid"; return 1; }
|
echo "[sing-box] Failed to get version"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
url="$(singbox_url_for_rid "$rid" "$ver")" || {
|
||||||
|
echo "[sing-box] Unsupported RID: $rid"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
echo "[+] Download sing-box: $url"
|
echo "[+] Download sing-box: $url"
|
||||||
|
|
||||||
tmp="$(mktemp -d)"
|
tmp="$(mktemp -d)"
|
||||||
curl -fL "$url" -o "$tmp/singbox.tar.gz" || { rm -rf "$tmp"; return 1; }
|
curl -fL "$url" -o "$tmp/singbox.tar.gz" || {
|
||||||
tar -C "$tmp" -xzf "$tmp/singbox.tar.gz" || { rm -rf "$tmp"; return 1; }
|
rm -rf "$tmp"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
tar -C "$tmp" -xzf "$tmp/singbox.tar.gz" || {
|
||||||
|
rm -rf "$tmp"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
bin="$(find "$tmp" -type f -name 'sing-box' | head -n1 || true)"
|
bin="$(find "$tmp" -type f -name 'sing-box' | head -n1 || true)"
|
||||||
[[ -n "$bin" ]] || { echo "[!] sing-box unpack failed"; rm -rf "$tmp"; return 1; }
|
[[ -n "$bin" ]] || {
|
||||||
|
echo "[!] sing-box unpack failed"
|
||||||
|
rm -rf "$tmp"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
install -m 755 "$bin" "$outdir/sing-box" || { rm -rf "$tmp"; return 1; }
|
install -m 755 "$bin" "$outdir/sing-box" || {
|
||||||
|
rm -rf "$tmp"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
cronet="$(find "$tmp" -type f -name 'libcronet*.so*' | head -n1 || true)"
|
cronet="$(find "$tmp" -type f -name 'libcronet*.so*' | head -n1 || true)"
|
||||||
[[ -n "$cronet" ]] && install -m 644 "$cronet" "$outdir/libcronet.so" || true
|
[[ -n "$cronet" ]] && install -m 644 "$cronet" "$outdir/libcronet.so" || true
|
||||||
@@ -378,20 +619,44 @@ download_geo_assets() {
|
|||||||
mkdir -p "$bin_dir" "$srss_dir"
|
mkdir -p "$bin_dir" "$srss_dir"
|
||||||
|
|
||||||
echo "[+] Download Xray Geo to ${bin_dir}"
|
echo "[+] Download Xray Geo to ${bin_dir}"
|
||||||
curl -fsSL -o "$bin_dir/geosite.dat" "https://github.com/Loyalsoldier/V2ray-rules-dat/releases/latest/download/geosite.dat"
|
curl -fsSL -o "$bin_dir/geosite.dat" \
|
||||||
curl -fsSL -o "$bin_dir/geoip.dat" "https://github.com/Loyalsoldier/V2ray-rules-dat/releases/latest/download/geoip.dat"
|
"https://github.com/Loyalsoldier/V2ray-rules-dat/releases/latest/download/geosite.dat"
|
||||||
curl -fsSL -o "$bin_dir/geoip-only-cn-private.dat" "https://raw.githubusercontent.com/Loyalsoldier/geoip/release/geoip-only-cn-private.dat"
|
curl -fsSL -o "$bin_dir/geoip.dat" \
|
||||||
curl -fsSL -o "$bin_dir/Country.mmdb" "https://raw.githubusercontent.com/Loyalsoldier/geoip/release/Country.mmdb"
|
"https://github.com/Loyalsoldier/V2ray-rules-dat/releases/latest/download/geoip.dat"
|
||||||
|
curl -fsSL -o "$bin_dir/geoip-only-cn-private.dat" \
|
||||||
|
"https://raw.githubusercontent.com/Loyalsoldier/geoip/release/geoip-only-cn-private.dat"
|
||||||
|
curl -fsSL -o "$bin_dir/Country.mmdb" \
|
||||||
|
"https://raw.githubusercontent.com/Loyalsoldier/geoip/release/Country.mmdb"
|
||||||
|
|
||||||
echo "[+] Download sing-box rule DB & rule-sets"
|
echo "[+] Download sing-box rule DB & rule-sets"
|
||||||
curl -fsSL -o "$bin_dir/geoip.metadb" "https://github.com/MetaCubeX/meta-rules-dat/releases/latest/download/geoip.metadb"
|
curl -fsSL -o "$bin_dir/geoip.metadb" \
|
||||||
|
"https://github.com/MetaCubeX/meta-rules-dat/releases/latest/download/geoip.metadb"
|
||||||
|
|
||||||
for f in geoip-private.srs geoip-cn.srs geoip-facebook.srs geoip-fastly.srs geoip-google.srs geoip-netflix.srs geoip-telegram.srs geoip-twitter.srs; do
|
for f in \
|
||||||
curl -fsSL -o "$srss_dir/$f" "https://raw.githubusercontent.com/2dust/sing-box-rules/refs/heads/rule-set-geoip/$f"
|
geoip-private.srs \
|
||||||
|
geoip-cn.srs \
|
||||||
|
geoip-facebook.srs \
|
||||||
|
geoip-fastly.srs \
|
||||||
|
geoip-google.srs \
|
||||||
|
geoip-netflix.srs \
|
||||||
|
geoip-telegram.srs \
|
||||||
|
geoip-twitter.srs
|
||||||
|
do
|
||||||
|
curl -fsSL -o "$srss_dir/$f" \
|
||||||
|
"https://raw.githubusercontent.com/2dust/sing-box-rules/refs/heads/rule-set-geoip/$f"
|
||||||
done
|
done
|
||||||
|
|
||||||
for f in geosite-cn.srs geosite-gfw.srs geosite-google.srs geosite-greatfire.srs geosite-geolocation-cn.srs geosite-category-ads-all.srs geosite-private.srs; do
|
for f in \
|
||||||
curl -fsSL -o "$srss_dir/$f" "https://raw.githubusercontent.com/2dust/sing-box-rules/refs/heads/rule-set-geosite/$f"
|
geosite-cn.srs \
|
||||||
|
geosite-gfw.srs \
|
||||||
|
geosite-google.srs \
|
||||||
|
geosite-greatfire.srs \
|
||||||
|
geosite-geolocation-cn.srs \
|
||||||
|
geosite-category-ads-all.srs \
|
||||||
|
geosite-private.srs
|
||||||
|
do
|
||||||
|
curl -fsSL -o "$srss_dir/$f" \
|
||||||
|
"https://raw.githubusercontent.com/2dust/sing-box-rules/refs/heads/rule-set-geosite/$f"
|
||||||
done
|
done
|
||||||
|
|
||||||
unify_geo_layout "$outroot"
|
unify_geo_layout "$outroot"
|
||||||
@@ -404,13 +669,26 @@ populate_assets_zip_mode() {
|
|||||||
local tmp=""
|
local tmp=""
|
||||||
local nested_dir=""
|
local nested_dir=""
|
||||||
|
|
||||||
url="$(bundle_url_for_rid "$rid")" || { echo "[!] Bundle unsupported RID: $rid"; return 1; }
|
url="$(bundle_url_for_rid "$rid")" || {
|
||||||
|
echo "[!] Bundle unsupported RID: $rid"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
echo "[+] Try v2rayN bundle archive: $url"
|
echo "[+] Try v2rayN bundle archive: $url"
|
||||||
|
|
||||||
tmp="$(mktemp -d)"
|
tmp="$(mktemp -d)"
|
||||||
curl -fL "$url" -o "$tmp/v2rayn.zip" || { echo "[!] Bundle download failed"; rm -rf "$tmp"; return 1; }
|
|
||||||
unzip -q "$tmp/v2rayn.zip" -d "$tmp" || { echo "[!] Bundle unzip failed"; rm -rf "$tmp"; return 1; }
|
curl -fL "$url" -o "$tmp/v2rayn.zip" || {
|
||||||
|
echo "[!] Bundle download failed"
|
||||||
|
rm -rf "$tmp"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
unzip -q "$tmp/v2rayn.zip" -d "$tmp" || {
|
||||||
|
echo "[!] Bundle unzip failed"
|
||||||
|
rm -rf "$tmp"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
if [[ -d "$tmp/bin" ]]; then
|
if [[ -d "$tmp/bin" ]]; then
|
||||||
mkdir -p "$outroot/bin"
|
mkdir -p "$outroot/bin"
|
||||||
@@ -423,6 +701,7 @@ populate_assets_zip_mode() {
|
|||||||
find "$outroot" -type d -name "mihomo" -prune -exec rm -rf {} + 2>/dev/null || true
|
find "$outroot" -type d -name "mihomo" -prune -exec rm -rf {} + 2>/dev/null || true
|
||||||
|
|
||||||
nested_dir="$(find "$outroot" -maxdepth 1 -type d -name 'v2rayN-linux-*' | head -n1 || true)"
|
nested_dir="$(find "$outroot" -maxdepth 1 -type d -name 'v2rayN-linux-*' | head -n1 || true)"
|
||||||
|
|
||||||
if [[ -n "$nested_dir" && -d "$nested_dir/bin" ]]; then
|
if [[ -n "$nested_dir" && -d "$nested_dir/bin" ]]; then
|
||||||
mkdir -p "$outroot/bin"
|
mkdir -p "$outroot/bin"
|
||||||
rsync -a "$nested_dir/bin/" "$outroot/bin/"
|
rsync -a "$nested_dir/bin/" "$outroot/bin/"
|
||||||
@@ -442,14 +721,17 @@ populate_assets_netcore_mode() {
|
|||||||
mkdir -p "$outroot/bin/xray" "$outroot/bin/sing_box"
|
mkdir -p "$outroot/bin/xray" "$outroot/bin/sing_box"
|
||||||
|
|
||||||
if [[ "$WITH_CORE" == "xray" || "$WITH_CORE" == "both" ]]; then
|
if [[ "$WITH_CORE" == "xray" || "$WITH_CORE" == "both" ]]; then
|
||||||
download_xray "$outroot/bin/xray" "$rid" || echo "[!] xray download failed (skipped)"
|
download_xray "$outroot/bin/xray" "$rid" \
|
||||||
|
|| echo "[!] xray download failed (skipped)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$WITH_CORE" == "sing-box" || "$WITH_CORE" == "both" ]]; then
|
if [[ "$WITH_CORE" == "sing-box" || "$WITH_CORE" == "both" ]]; then
|
||||||
download_singbox "$outroot/bin/sing_box" "$rid" || echo "[!] sing-box download failed (skipped)"
|
download_singbox "$outroot/bin/sing_box" "$rid" \
|
||||||
|
|| echo "[!] sing-box download failed (skipped)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
download_geo_assets "$outroot" || echo "[!] Geo rules download failed (skipped)"
|
download_geo_assets "$outroot" \
|
||||||
|
|| echo "[!] Geo rules download failed (skipped)"
|
||||||
}
|
}
|
||||||
|
|
||||||
stage_runtime_assets() {
|
stage_runtime_assets() {
|
||||||
@@ -472,21 +754,36 @@ stage_runtime_assets() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe_target() {
|
describe_target() {
|
||||||
local short="$1"
|
printf '%s\n%s\n%s\n' \
|
||||||
|
"$X86_RID" \
|
||||||
case "$short" in
|
"$X86_RPM_ARCH" \
|
||||||
riscv64) printf '%s\n%s\n%s\n' "linux-riscv64" "riscv64" "riscv64" ;;
|
"$X86_RPM_ARCH"
|
||||||
*) echo "Unknown arch '$short' (use riscv64)" >&2; return 1 ;;
|
|
||||||
esac
|
|
||||||
}
|
}
|
||||||
|
|
||||||
publish_binary() {
|
publish_binary() {
|
||||||
local rid="$1"
|
local rid="$1"
|
||||||
|
|
||||||
|
patch_x86_sdk
|
||||||
|
|
||||||
dotnet clean "$PROJECT" -c Release
|
dotnet clean "$PROJECT" -c Release
|
||||||
rm -rf "$(dirname "$PROJECT")/bin/Release/net10.0" || true
|
rm -rf "$(dirname "$PROJECT")/bin/Release/net10.0" || true
|
||||||
dotnet restore "$PROJECT"
|
|
||||||
dotnet publish "$PROJECT" -c Release -r "$rid" -p:PublishSingleFile=false -p:SelfContained=true
|
dotnet restore "$PROJECT" \
|
||||||
|
-r "$rid" \
|
||||||
|
--source "$NUGET_CACHE_DIR" \
|
||||||
|
--source "https://api.nuget.org/v3/index.json" \
|
||||||
|
-p:SelfContained=true \
|
||||||
|
-p:EnableWindowsTargeting=true \
|
||||||
|
--force-evaluate
|
||||||
|
|
||||||
|
dotnet publish "$PROJECT" \
|
||||||
|
-c Release \
|
||||||
|
-r "$rid" \
|
||||||
|
--source "$NUGET_CACHE_DIR" \
|
||||||
|
--source "https://api.nuget.org/v3/index.json" \
|
||||||
|
-p:PublishSingleFile=false \
|
||||||
|
-p:SelfContained=true \
|
||||||
|
--no-restore
|
||||||
}
|
}
|
||||||
|
|
||||||
write_spec_file() {
|
write_spec_file() {
|
||||||
@@ -505,7 +802,7 @@ Summary: v2rayN (Avalonia) GUI client for Linux
|
|||||||
License: GPL-3.0-only
|
License: GPL-3.0-only
|
||||||
URL: https://github.com/2dust/v2rayN
|
URL: https://github.com/2dust/v2rayN
|
||||||
BugURL: https://github.com/2dust/v2rayN/issues
|
BugURL: https://github.com/2dust/v2rayN/issues
|
||||||
ExclusiveArch: riscv64
|
ExclusiveArch: i686
|
||||||
Source0: __PKGROOT__.tar.gz
|
Source0: __PKGROOT__.tar.gz
|
||||||
|
|
||||||
Requires: cairo, pango, openssl, mesa-libEGL, mesa-libGL
|
Requires: cairo, pango, openssl, mesa-libEGL, mesa-libGL
|
||||||
@@ -603,7 +900,10 @@ package_binary() {
|
|||||||
local f=""
|
local f=""
|
||||||
|
|
||||||
pubdir="$(dirname "$PROJECT")/bin/Release/net10.0/${rid}/publish"
|
pubdir="$(dirname "$PROJECT")/bin/Release/net10.0/${rid}/publish"
|
||||||
[[ -d "$pubdir" ]] || { echo "Publish directory not found: $pubdir"; return 1; }
|
[[ -d "$pubdir" ]] || {
|
||||||
|
echo "Publish directory not found: $pubdir"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
workdir="$(mktemp -d)"
|
workdir="$(mktemp -d)"
|
||||||
trap '[[ -n "${workdir:-}" ]] && rm -rf "$workdir"' RETURN
|
trap '[[ -n "${workdir:-}" ]] && rm -rf "$workdir"' RETURN
|
||||||
@@ -613,23 +913,41 @@ package_binary() {
|
|||||||
|
|
||||||
project_dir="$(cd "$(dirname "$PROJECT")" && pwd)"
|
project_dir="$(cd "$(dirname "$PROJECT")" && pwd)"
|
||||||
icon_candidate="$project_dir/v2rayN.png"
|
icon_candidate="$project_dir/v2rayN.png"
|
||||||
[[ -f "$icon_candidate" ]] || { echo "Required icon not found: $icon_candidate"; return 1; }
|
|
||||||
|
[[ -f "$icon_candidate" ]] || {
|
||||||
|
echo "Required icon not found: $icon_candidate"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
cp "$icon_candidate" "$workdir/$PKGROOT/v2rayn.png"
|
cp "$icon_candidate" "$workdir/$PKGROOT/v2rayn.png"
|
||||||
|
|
||||||
stage_runtime_assets "$workdir/$PKGROOT" "$rid"
|
stage_runtime_assets "$workdir/$PKGROOT" "$rid"
|
||||||
|
|
||||||
rpmdev-setuptree
|
rpmdev-setuptree
|
||||||
|
|
||||||
sourcedir="${RPM_TOPDIR}/SOURCES"
|
sourcedir="${RPM_TOPDIR}/SOURCES"
|
||||||
specdir="${RPM_TOPDIR}/SPECS"
|
specdir="${RPM_TOPDIR}/SPECS"
|
||||||
specfile="${specdir}/v2rayN.spec"
|
specfile="${specdir}/v2rayN.spec"
|
||||||
|
|
||||||
mkdir -p "$sourcedir" "$specdir"
|
mkdir -p "$sourcedir" "$specdir"
|
||||||
tar -C "$workdir" -czf "$sourcedir/$PKGROOT.tar.gz" "$PKGROOT"
|
|
||||||
|
tar -C "$workdir" \
|
||||||
|
-czf "$sourcedir/$PKGROOT.tar.gz" \
|
||||||
|
"$PKGROOT"
|
||||||
|
|
||||||
write_spec_file "$specfile"
|
write_spec_file "$specfile"
|
||||||
|
|
||||||
|
if [[ -n "${RPM_STRIP:-}" ]]; then
|
||||||
|
rpmbuild -ba "$specfile" \
|
||||||
|
--target "$rpm_target" \
|
||||||
|
--define "__strip ${RPM_STRIP}" \
|
||||||
|
--define "__objdump ${RPM_OBJDUMP}"
|
||||||
|
else
|
||||||
rpmbuild -ba "$specfile" --target "$rpm_target"
|
rpmbuild -ba "$specfile" --target "$rpm_target"
|
||||||
|
fi
|
||||||
|
|
||||||
echo "Build done for $short. RPM at:"
|
echo "Build done for $short. RPM at:"
|
||||||
|
|
||||||
for f in "${RPM_TOPDIR}/RPMS/${archdir}/v2rayN-${VERSION}-1"*.rpm; do
|
for f in "${RPM_TOPDIR}/RPMS/${archdir}/v2rayN-${VERSION}-1"*.rpm; do
|
||||||
[[ -e "$f" ]] || continue
|
[[ -e "$f" ]] || continue
|
||||||
echo " $f"
|
echo " $f"
|
||||||
@@ -637,23 +955,16 @@ package_binary() {
|
|||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
select_targets() {
|
|
||||||
printf '%s\n' riscv64
|
|
||||||
}
|
|
||||||
|
|
||||||
build_one_target() {
|
build_one_target() {
|
||||||
local short="$1"
|
local short="i686"
|
||||||
local meta=()
|
local rid="$X86_RID"
|
||||||
local rid=""
|
local rpm_target="$X86_RPM_ARCH"
|
||||||
local rpm_target=""
|
local archdir="$X86_RPM_ARCH"
|
||||||
local archdir=""
|
|
||||||
|
|
||||||
mapfile -t meta < <(describe_target "$short") || return 1
|
|
||||||
rid="${meta[0]}"
|
|
||||||
rpm_target="${meta[1]}"
|
|
||||||
archdir="${meta[2]}"
|
|
||||||
|
|
||||||
echo "[*] Building for target: $short (RID=$rid, RPM --target $rpm_target)"
|
echo "[*] Building for target: $short (RID=$rid, RPM --target $rpm_target)"
|
||||||
|
|
||||||
|
prepare_cross_rpm_tools "$short"
|
||||||
|
prepare_dotnet_sdk
|
||||||
publish_binary "$rid"
|
publish_binary "$rid"
|
||||||
package_binary "$short" "$rid" "$rpm_target" "$archdir"
|
package_binary "$short" "$rid" "$rpm_target" "$archdir"
|
||||||
}
|
}
|
||||||
@@ -662,32 +973,31 @@ print_summary() {
|
|||||||
local rp=""
|
local rp=""
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "================ Build Summary ================"
|
echo "================ Build Summary ================="
|
||||||
if [[ "${#BUILT_RPMS[@]}" -gt 0 ]]; then
|
if [[ "${#BUILT_RPMS[@]}" -gt 0 ]]; then
|
||||||
|
echo "Output directory: ${RPM_TOPDIR}"
|
||||||
for rp in "${BUILT_RPMS[@]}"; do
|
for rp in "${BUILT_RPMS[@]}"; do
|
||||||
echo "$rp"
|
echo "$rp"
|
||||||
done
|
done
|
||||||
else
|
else
|
||||||
echo "No RPMs detected in summary (check build logs above)."
|
echo "No RPMs detected in summary (check build logs above)."
|
||||||
fi
|
fi
|
||||||
echo "=============================================="
|
echo "==============================================="
|
||||||
}
|
}
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
local targets=()
|
|
||||||
local arch=""
|
|
||||||
|
|
||||||
parse_args "$@"
|
parse_args "$@"
|
||||||
detect_environment
|
detect_environment
|
||||||
install_dependencies
|
install_dependencies
|
||||||
prepare_workspace
|
prepare_workspace
|
||||||
resolve_version
|
resolve_version
|
||||||
|
|
||||||
mapfile -t targets < <(select_targets)
|
download_nuget_packages || die \
|
||||||
|
"Failed to download prebuilt NuGet packages."
|
||||||
|
|
||||||
for arch in "${targets[@]}"; do
|
build_one_target
|
||||||
build_one_target "$arch"
|
|
||||||
done
|
rm -rf "${NUGET_CACHE_DIR:-}" || true
|
||||||
|
|
||||||
print_summary
|
print_summary
|
||||||
}
|
}
|
||||||
Regular → Executable
+248
-11
@@ -14,6 +14,30 @@ PKGROOT="v2rayN-publish"
|
|||||||
PROJECT_HINT="v2rayN.Desktop/v2rayN.Desktop.csproj"
|
PROJECT_HINT="v2rayN.Desktop/v2rayN.Desktop.csproj"
|
||||||
RPM_TOPDIR="${HOME}/rpmbuild"
|
RPM_TOPDIR="${HOME}/rpmbuild"
|
||||||
|
|
||||||
|
NUGET_CACHE_DIR=""
|
||||||
|
DOTNET_NUPKG_BASE="${DOTNET_NUPKG_BASE:-https://github.com/autorepobot/dotnet-nupkg-prebuild/releases/download}"
|
||||||
|
SKIASHARP_BASE="${SKIASHARP_BASE:-https://github.com/autorepobot/SkiaSharp-ppc64le/releases/download}"
|
||||||
|
SkiaSharp_VERSION="${SkiaSharp_VERSION:-3.119.4}"
|
||||||
|
HarfBuzzSharp_VERSION="${HarfBuzzSharp_VERSION:-8.3.1.5}"
|
||||||
|
|
||||||
|
DOTNET_SDK_VERSION="${DOTNET_SDK_VERSION:-10.0.114}"
|
||||||
|
|
||||||
|
DOTNET_RISCV_VERSION="${DOTNET_RISCV_VERSION:-10.0.114}"
|
||||||
|
DOTNET_RISCV_BASE="${DOTNET_RISCV_BASE:-https://github.com/autorepobot/dotnet-riscv/releases/download}"
|
||||||
|
DOTNET_RISCV_FILE="dotnet-sdk-${DOTNET_RISCV_VERSION}-linux-riscv64.tar.gz"
|
||||||
|
DOTNET_RISCV_SDK_URL="${DOTNET_RISCV_SDK_URL:-${DOTNET_RISCV_BASE}/${DOTNET_RISCV_VERSION}/${DOTNET_RISCV_FILE}}"
|
||||||
|
|
||||||
|
DOTNET_PPC64LE_VERSION="${DOTNET_PPC64LE_VERSION:-10.0.114}"
|
||||||
|
DOTNET_PPC64LE_BASE="${DOTNET_PPC64LE_BASE:-https://github.com/IBM/dotnet-s390x/releases/download}"
|
||||||
|
DOTNET_PPC64LE_FILE="dotnet-sdk-${DOTNET_PPC64LE_VERSION}-linux-ppc64le.tar.gz"
|
||||||
|
DOTNET_PPC64LE_SDK_URL="${DOTNET_PPC64LE_SDK_URL:-${DOTNET_PPC64LE_BASE}/v${DOTNET_PPC64LE_VERSION}/${DOTNET_PPC64LE_FILE}}"
|
||||||
|
|
||||||
|
DOTNET_LOONGARCH_VERSION="${DOTNET_LOONGARCH_VERSION:-10.0.114}"
|
||||||
|
DOTNET_LOONGARCH_TAG="${DOTNET_LOONGARCH_TAG:-v10.0.114-loongarch64}"
|
||||||
|
DOTNET_LOONGARCH_BASE="${DOTNET_LOONGARCH_BASE:-https://github.com/loongson/dotnet/releases/download}"
|
||||||
|
DOTNET_LOONGARCH_FILE="dotnet-sdk-${DOTNET_LOONGARCH_VERSION}-linux-loongarch64.tar.gz"
|
||||||
|
DOTNET_LOONGARCH_SDK_URL="${DOTNET_LOONGARCH_SDK_URL:-${DOTNET_LOONGARCH_BASE}/${DOTNET_LOONGARCH_TAG}/${DOTNET_LOONGARCH_FILE}}"
|
||||||
|
|
||||||
OS_ID=""
|
OS_ID=""
|
||||||
OS_NAME=""
|
OS_NAME=""
|
||||||
OS_VERSION_ID=""
|
OS_VERSION_ID=""
|
||||||
@@ -23,6 +47,9 @@ PROJECT=""
|
|||||||
VERSION=""
|
VERSION=""
|
||||||
BUILT_ALL=0
|
BUILT_ALL=0
|
||||||
|
|
||||||
|
RPM_STRIP=""
|
||||||
|
RPM_OBJDUMP=""
|
||||||
|
|
||||||
declare -a BUILT_RPMS=()
|
declare -a BUILT_RPMS=()
|
||||||
|
|
||||||
die() {
|
die() {
|
||||||
@@ -30,6 +57,13 @@ die() {
|
|||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get_runtime_version() {
|
||||||
|
local sdk_ver="$1"
|
||||||
|
local prefix="${sdk_ver%.*}"
|
||||||
|
local patch="${sdk_ver##*.}"
|
||||||
|
echo "${prefix}.${patch#1}"
|
||||||
|
}
|
||||||
|
|
||||||
parse_args() {
|
parse_args() {
|
||||||
local first_arg="${1:-}"
|
local first_arg="${1:-}"
|
||||||
|
|
||||||
@@ -81,8 +115,8 @@ This script only supports: RHEL / Rocky / AlmaLinux / Fedora / CentOS."
|
|||||||
esac
|
esac
|
||||||
|
|
||||||
case "$HOST_ARCH" in
|
case "$HOST_ARCH" in
|
||||||
x86_64|aarch64) ;;
|
x86_64|aarch64|riscv64|ppc64le|loongarch64) ;;
|
||||||
*) die "Only supports aarch64 / x86_64" ;;
|
*) die "Only supports x86_64 / aarch64 / riscv64 / ppc64le / loongarch64" ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
current_kernel="$(uname -r)"
|
current_kernel="$(uname -r)"
|
||||||
@@ -96,8 +130,31 @@ install_dependencies() {
|
|||||||
local install_ok=0
|
local install_ok=0
|
||||||
|
|
||||||
if command -v dnf >/dev/null 2>&1; then
|
if command -v dnf >/dev/null 2>&1; then
|
||||||
sudo dnf -y install rpm-build rpmdevtools curl unzip tar jq rsync dotnet-sdk-10.0 \
|
sudo dnf -y install \
|
||||||
|
rpm-build rpmdevtools curl unzip tar jq rsync ca-certificates git \
|
||||||
|
desktop-file-utils wget libicu \
|
||||||
&& install_ok=1
|
&& install_ok=1
|
||||||
|
|
||||||
|
case "$HOST_ARCH" in
|
||||||
|
x86_64|aarch64)
|
||||||
|
wget -q https://dot.net/v1/dotnet-install.sh
|
||||||
|
chmod +x dotnet-install.sh
|
||||||
|
./dotnet-install.sh \
|
||||||
|
--version "$DOTNET_SDK_VERSION" \
|
||||||
|
--install-dir "$HOME/.dotnet"
|
||||||
|
|
||||||
|
export PATH="$HOME/.dotnet:$PATH"
|
||||||
|
export DOTNET_ROOT="$HOME/.dotnet"
|
||||||
|
|
||||||
|
dotnet --info >/dev/null 2>&1 && install_ok=1
|
||||||
|
;;
|
||||||
|
riscv64|ppc64le|loongarch64)
|
||||||
|
install_ok=1
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
die "Only supports x86_64 / aarch64 / riscv64 / ppc64le / loongarch64"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$install_ok" -ne 1 ]]; then
|
if [[ "$install_ok" -ne 1 ]]; then
|
||||||
@@ -107,6 +164,48 @@ install_dependencies() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
prepare_cross_rpm_tools() {
|
||||||
|
local target="$1"
|
||||||
|
local package=""
|
||||||
|
local strip_tool=""
|
||||||
|
local objdump_tool=""
|
||||||
|
|
||||||
|
case "$HOST_ARCH:$target" in
|
||||||
|
x86_64:ppc64le|aarch64:ppc64le)
|
||||||
|
package="gcc-powerpc64le-linux-gnu"
|
||||||
|
strip_tool="powerpc64le-linux-gnu-strip"
|
||||||
|
objdump_tool="powerpc64le-linux-gnu-objdump"
|
||||||
|
;;
|
||||||
|
x86_64:riscv64|aarch64:riscv64)
|
||||||
|
package="gcc-riscv64-linux-gnu"
|
||||||
|
strip_tool="riscv64-linux-gnu-strip"
|
||||||
|
objdump_tool="riscv64-linux-gnu-objdump"
|
||||||
|
;;
|
||||||
|
x86_64:loong64|aarch64:loong64)
|
||||||
|
package="gcc-loongarch64-linux-gnu"
|
||||||
|
strip_tool="loongarch64-linux-gnu-strip"
|
||||||
|
objdump_tool="loongarch64-linux-gnu-objdump"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
RPM_STRIP=""
|
||||||
|
RPM_OBJDUMP=""
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo "[+] Install cross RPM tools for $target: $package"
|
||||||
|
sudo dnf -y install "$package"
|
||||||
|
|
||||||
|
command -v "$strip_tool" >/dev/null 2>&1 || die "Cross strip tool not found: $strip_tool"
|
||||||
|
command -v "$objdump_tool" >/dev/null 2>&1 || die "Cross objdump tool not found: $objdump_tool"
|
||||||
|
|
||||||
|
RPM_STRIP="$(command -v "$strip_tool")"
|
||||||
|
RPM_OBJDUMP="$(command -v "$objdump_tool")"
|
||||||
|
|
||||||
|
echo "[OK] RPM strip: $RPM_STRIP"
|
||||||
|
echo "[OK] RPM objdump: $RPM_OBJDUMP"
|
||||||
|
}
|
||||||
|
|
||||||
prepare_workspace() {
|
prepare_workspace() {
|
||||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
cd "$SCRIPT_DIR"
|
cd "$SCRIPT_DIR"
|
||||||
@@ -239,6 +338,104 @@ resolve_version() {
|
|||||||
echo "[*] GUI version resolved as: ${VERSION}"
|
echo "[*] GUI version resolved as: ${VERSION}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
download_nuget_packages() {
|
||||||
|
NUGET_CACHE_DIR="$(mktemp -d)"
|
||||||
|
|
||||||
|
local riscv_rt ppc64le_rt loong_rt
|
||||||
|
riscv_rt="$(get_runtime_version "$DOTNET_RISCV_VERSION")"
|
||||||
|
ppc64le_rt="$(get_runtime_version "$DOTNET_PPC64LE_VERSION")"
|
||||||
|
loong_rt="$(get_runtime_version "$DOTNET_LOONGARCH_VERSION")"
|
||||||
|
|
||||||
|
local urls=(
|
||||||
|
"${DOTNET_NUPKG_BASE}/${loong_rt}/Microsoft.AspNetCore.App.Runtime.linux-loongarch64.${loong_rt}.nupkg"
|
||||||
|
"${DOTNET_NUPKG_BASE}/${ppc64le_rt}/Microsoft.AspNetCore.App.Runtime.linux-ppc64le.${ppc64le_rt}.nupkg"
|
||||||
|
"${DOTNET_NUPKG_BASE}/${riscv_rt}/Microsoft.AspNetCore.App.Runtime.linux-riscv64.${riscv_rt}.nupkg"
|
||||||
|
"${DOTNET_NUPKG_BASE}/${loong_rt}/Microsoft.NETCore.App.Host.linux-loongarch64.${loong_rt}.nupkg"
|
||||||
|
"${DOTNET_NUPKG_BASE}/${ppc64le_rt}/Microsoft.NETCore.App.Host.linux-ppc64le.${ppc64le_rt}.nupkg"
|
||||||
|
"${DOTNET_NUPKG_BASE}/${riscv_rt}/Microsoft.NETCore.App.Host.linux-riscv64.${riscv_rt}.nupkg"
|
||||||
|
"${DOTNET_NUPKG_BASE}/${loong_rt}/Microsoft.NETCore.App.Runtime.linux-loongarch64.${loong_rt}.nupkg"
|
||||||
|
"${DOTNET_NUPKG_BASE}/${ppc64le_rt}/Microsoft.NETCore.App.Runtime.linux-ppc64le.${ppc64le_rt}.nupkg"
|
||||||
|
"${DOTNET_NUPKG_BASE}/${riscv_rt}/Microsoft.NETCore.App.Runtime.linux-riscv64.${riscv_rt}.nupkg"
|
||||||
|
"${SKIASHARP_BASE}/v${SkiaSharp_VERSION}/SkiaSharp.NativeAssets.Linux.${SkiaSharp_VERSION}.nupkg"
|
||||||
|
"${SKIASHARP_BASE}/v${SkiaSharp_VERSION}/HarfBuzzSharp.NativeAssets.Linux.${HarfBuzzSharp_VERSION}.nupkg"
|
||||||
|
)
|
||||||
|
|
||||||
|
echo "[+] Downloading prebuilt NuGet packages to: $NUGET_CACHE_DIR"
|
||||||
|
local u pkg_name
|
||||||
|
for u in "${urls[@]}"; do
|
||||||
|
pkg_name="${u##*/}"
|
||||||
|
echo " -> $pkg_name"
|
||||||
|
curl -fL "$u" -o "$NUGET_CACHE_DIR/$pkg_name" || {
|
||||||
|
echo "[!] Failed to download $u" >&2
|
||||||
|
rm -rf "$NUGET_CACHE_DIR"
|
||||||
|
NUGET_CACHE_DIR=""
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "[OK] Prebuilt NuGet packages ready in $NUGET_CACHE_DIR"
|
||||||
|
}
|
||||||
|
|
||||||
|
prepare_dotnet_sdk() {
|
||||||
|
local short="$1"
|
||||||
|
local sdk_url=""
|
||||||
|
local sdk_file=""
|
||||||
|
local sdk_dir=""
|
||||||
|
local tmp=""
|
||||||
|
|
||||||
|
case "$HOST_ARCH:$short" in
|
||||||
|
x86_64:x64|aarch64:arm64)
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
riscv64:riscv64)
|
||||||
|
sdk_url="$DOTNET_RISCV_SDK_URL"
|
||||||
|
sdk_file="$DOTNET_RISCV_FILE"
|
||||||
|
;;
|
||||||
|
ppc64le:ppc64le)
|
||||||
|
sdk_url="$DOTNET_PPC64LE_SDK_URL"
|
||||||
|
sdk_file="$DOTNET_PPC64LE_FILE"
|
||||||
|
;;
|
||||||
|
loongarch64:loong64)
|
||||||
|
sdk_url="$DOTNET_LOONGARCH_SDK_URL"
|
||||||
|
sdk_file="$DOTNET_LOONGARCH_FILE"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
sdk_dir="$(mktemp -d)"
|
||||||
|
tmp="$(mktemp -d)"
|
||||||
|
|
||||||
|
echo "[+] Download .NET SDK for $short: $sdk_url"
|
||||||
|
|
||||||
|
curl -fL "$sdk_url" -o "$tmp/$sdk_file" || {
|
||||||
|
rm -rf "$tmp" "$sdk_dir"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
tar -C "$sdk_dir" -xzf "$tmp/$sdk_file" || {
|
||||||
|
rm -rf "$tmp" "$sdk_dir"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
rm -rf "$tmp"
|
||||||
|
|
||||||
|
[[ -x "$sdk_dir/dotnet" ]] || {
|
||||||
|
rm -rf "$sdk_dir"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
export DOTNET_ROOT="$sdk_dir"
|
||||||
|
export PATH="$DOTNET_ROOT:$PATH"
|
||||||
|
hash -r
|
||||||
|
|
||||||
|
echo "[OK] .NET SDK for $short extracted to: $sdk_dir"
|
||||||
|
dotnet --info
|
||||||
|
|
||||||
|
DOTNET_SDK_DIR="$sdk_dir"
|
||||||
|
}
|
||||||
|
|
||||||
xray_url_for_rid() {
|
xray_url_for_rid() {
|
||||||
local rid="$1"
|
local rid="$1"
|
||||||
local ver="$2"
|
local ver="$2"
|
||||||
@@ -246,6 +443,9 @@ xray_url_for_rid() {
|
|||||||
case "$rid" in
|
case "$rid" in
|
||||||
linux-x64) echo "https://github.com/XTLS/Xray-core/releases/download/v${ver}/Xray-linux-64.zip" ;;
|
linux-x64) echo "https://github.com/XTLS/Xray-core/releases/download/v${ver}/Xray-linux-64.zip" ;;
|
||||||
linux-arm64) echo "https://github.com/XTLS/Xray-core/releases/download/v${ver}/Xray-linux-arm64-v8a.zip" ;;
|
linux-arm64) echo "https://github.com/XTLS/Xray-core/releases/download/v${ver}/Xray-linux-arm64-v8a.zip" ;;
|
||||||
|
linux-riscv64) echo "https://github.com/XTLS/Xray-core/releases/download/v${ver}/Xray-linux-riscv64.zip" ;;
|
||||||
|
linux-ppc64le) echo "https://github.com/XTLS/Xray-core/releases/download/v${ver}/Xray-linux-ppc64le.zip" ;;
|
||||||
|
linux-loongarch64) echo "https://github.com/XTLS/Xray-core/releases/download/v${ver}/Xray-linux-loong64.zip" ;;
|
||||||
*) return 1 ;;
|
*) return 1 ;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
@@ -257,6 +457,9 @@ singbox_url_for_rid() {
|
|||||||
case "$rid" in
|
case "$rid" in
|
||||||
linux-x64) echo "https://github.com/SagerNet/sing-box/releases/download/v${ver}/sing-box-${ver}-linux-amd64.tar.gz" ;;
|
linux-x64) echo "https://github.com/SagerNet/sing-box/releases/download/v${ver}/sing-box-${ver}-linux-amd64.tar.gz" ;;
|
||||||
linux-arm64) echo "https://github.com/SagerNet/sing-box/releases/download/v${ver}/sing-box-${ver}-linux-arm64.tar.gz" ;;
|
linux-arm64) echo "https://github.com/SagerNet/sing-box/releases/download/v${ver}/sing-box-${ver}-linux-arm64.tar.gz" ;;
|
||||||
|
linux-riscv64) echo "https://github.com/SagerNet/sing-box/releases/download/v${ver}/sing-box-${ver}-linux-riscv64.tar.gz" ;;
|
||||||
|
linux-ppc64le) echo "https://github.com/SagerNet/sing-box/releases/download/v${ver}/sing-box-${ver}-linux-ppc64le.tar.gz" ;;
|
||||||
|
linux-loongarch64) echo "https://github.com/SagerNet/sing-box/releases/download/v${ver}/sing-box-${ver}-linux-loong64.tar.gz" ;;
|
||||||
*) return 1 ;;
|
*) return 1 ;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
@@ -267,6 +470,9 @@ bundle_url_for_rid() {
|
|||||||
case "$rid" in
|
case "$rid" in
|
||||||
linux-x64) echo "https://raw.githubusercontent.com/2dust/v2rayN-core-bin/refs/heads/master/v2rayN-linux-64.zip" ;;
|
linux-x64) echo "https://raw.githubusercontent.com/2dust/v2rayN-core-bin/refs/heads/master/v2rayN-linux-64.zip" ;;
|
||||||
linux-arm64) echo "https://raw.githubusercontent.com/2dust/v2rayN-core-bin/refs/heads/master/v2rayN-linux-arm64.zip" ;;
|
linux-arm64) echo "https://raw.githubusercontent.com/2dust/v2rayN-core-bin/refs/heads/master/v2rayN-linux-arm64.zip" ;;
|
||||||
|
linux-riscv64) echo "https://raw.githubusercontent.com/2dust/v2rayN-core-bin/refs/heads/master/v2rayN-linux-riscv64.zip" ;;
|
||||||
|
linux-ppc64le) echo "https://raw.githubusercontent.com/2dust/v2rayN-core-bin/refs/heads/master/v2rayN-linux-ppc64le.zip" ;;
|
||||||
|
linux-loongarch64) echo "https://raw.githubusercontent.com/2dust/v2rayN-core-bin/refs/heads/master/v2rayN-linux-loong64.zip" ;;
|
||||||
*) return 1 ;;
|
*) return 1 ;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
@@ -288,7 +494,7 @@ download_xray() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
[[ -n "$ver" ]] || { echo "[xray] Failed to get version"; return 1; }
|
[[ -n "$ver" ]] || { echo "[xray] Failed to get version"; return 1; }
|
||||||
url="$(xray_url_for_rid "$rid" "$ver")" || { echo "[xray] Unsupported RID: $rid"; return 1; }
|
url="$(xray_url_for_rid "$rid" "$ver")" || { echo "[xray] Unsupported RID for upstream Xray: $rid (skipped)"; return 0; }
|
||||||
|
|
||||||
echo "[+] Download xray: $url"
|
echo "[+] Download xray: $url"
|
||||||
|
|
||||||
@@ -465,7 +671,10 @@ describe_target() {
|
|||||||
case "$short" in
|
case "$short" in
|
||||||
x64) printf '%s\n%s\n%s\n' "linux-x64" "x86_64" "x86_64" ;;
|
x64) printf '%s\n%s\n%s\n' "linux-x64" "x86_64" "x86_64" ;;
|
||||||
arm64) printf '%s\n%s\n%s\n' "linux-arm64" "aarch64" "aarch64" ;;
|
arm64) printf '%s\n%s\n%s\n' "linux-arm64" "aarch64" "aarch64" ;;
|
||||||
*) echo "Unknown arch '$short' (use x64|arm64)" >&2; return 1 ;;
|
riscv64) printf '%s\n%s\n%s\n' "linux-riscv64" "riscv64" "riscv64" ;;
|
||||||
|
ppc64le) printf '%s\n%s\n%s\n' "linux-ppc64le" "ppc64le" "ppc64le" ;;
|
||||||
|
loong64) printf '%s\n%s\n%s\n' "linux-loongarch64" "loongarch64" "loongarch64" ;;
|
||||||
|
*) echo "Unknown arch '$short' (use x64|arm64|riscv64|ppc64le|loong64)" >&2; return 1 ;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -474,8 +683,18 @@ publish_binary() {
|
|||||||
|
|
||||||
dotnet clean "$PROJECT" -c Release
|
dotnet clean "$PROJECT" -c Release
|
||||||
rm -rf "$(dirname "$PROJECT")/bin/Release/net10.0" || true
|
rm -rf "$(dirname "$PROJECT")/bin/Release/net10.0" || true
|
||||||
dotnet restore "$PROJECT"
|
dotnet restore "$PROJECT" \
|
||||||
dotnet publish "$PROJECT" -c Release -r "$rid" -p:PublishSingleFile=false -p:SelfContained=true
|
-r "$rid" \
|
||||||
|
--source "$NUGET_CACHE_DIR" \
|
||||||
|
--source "https://api.nuget.org/v3/index.json"
|
||||||
|
dotnet publish "$PROJECT" \
|
||||||
|
-c Release \
|
||||||
|
-r "$rid" \
|
||||||
|
--source "$NUGET_CACHE_DIR" \
|
||||||
|
--source "https://api.nuget.org/v3/index.json" \
|
||||||
|
-p:PublishSingleFile=false \
|
||||||
|
-p:SelfContained=true \
|
||||||
|
--no-restore
|
||||||
}
|
}
|
||||||
|
|
||||||
write_spec_file() {
|
write_spec_file() {
|
||||||
@@ -494,7 +713,7 @@ Summary: v2rayN (Avalonia) GUI client for Linux
|
|||||||
License: GPL-3.0-only
|
License: GPL-3.0-only
|
||||||
URL: https://github.com/2dust/v2rayN
|
URL: https://github.com/2dust/v2rayN
|
||||||
BugURL: https://github.com/2dust/v2rayN/issues
|
BugURL: https://github.com/2dust/v2rayN/issues
|
||||||
ExclusiveArch: aarch64 x86_64
|
ExclusiveArch: aarch64 x86_64 riscv64 ppc64le loongarch64
|
||||||
Source0: __PKGROOT__.tar.gz
|
Source0: __PKGROOT__.tar.gz
|
||||||
|
|
||||||
Requires: cairo, pango, openssl, mesa-libEGL, mesa-libGL
|
Requires: cairo, pango, openssl, mesa-libEGL, mesa-libGL
|
||||||
@@ -616,7 +835,15 @@ package_binary() {
|
|||||||
tar -C "$workdir" -czf "$sourcedir/$PKGROOT.tar.gz" "$PKGROOT"
|
tar -C "$workdir" -czf "$sourcedir/$PKGROOT.tar.gz" "$PKGROOT"
|
||||||
|
|
||||||
write_spec_file "$specfile"
|
write_spec_file "$specfile"
|
||||||
|
|
||||||
|
if [[ -n "${RPM_STRIP:-}" ]]; then
|
||||||
|
rpmbuild -ba "$specfile" \
|
||||||
|
--target "$rpm_target" \
|
||||||
|
--define "__strip ${RPM_STRIP}" \
|
||||||
|
--define "__objdump ${RPM_OBJDUMP}"
|
||||||
|
else
|
||||||
rpmbuild -ba "$specfile" --target "$rpm_target"
|
rpmbuild -ba "$specfile" --target "$rpm_target"
|
||||||
|
fi
|
||||||
|
|
||||||
echo "Build done for $short. RPM at:"
|
echo "Build done for $short. RPM at:"
|
||||||
for f in "${RPM_TOPDIR}/RPMS/${archdir}/v2rayN-${VERSION}-1"*.rpm; do
|
for f in "${RPM_TOPDIR}/RPMS/${archdir}/v2rayN-${VERSION}-1"*.rpm; do
|
||||||
@@ -628,9 +855,12 @@ package_binary() {
|
|||||||
|
|
||||||
select_targets() {
|
select_targets() {
|
||||||
case "${ARCH_OVERRIDE:-}" in
|
case "${ARCH_OVERRIDE:-}" in
|
||||||
all) printf '%s\n' x64 arm64 ;;
|
all) printf '%s\n' x64 arm64 riscv64 ppc64le loong64 ;;
|
||||||
x64|amd64) printf '%s\n' x64 ;;
|
x64|amd64) printf '%s\n' x64 ;;
|
||||||
arm64|aarch64) printf '%s\n' arm64 ;;
|
arm64|aarch64) printf '%s\n' arm64 ;;
|
||||||
|
riscv64) printf '%s\n' riscv64 ;;
|
||||||
|
ppc64le|ppc64el) printf '%s\n' ppc64le ;;
|
||||||
|
loong64|loongarch64) printf '%s\n' loong64 ;;
|
||||||
"")
|
"")
|
||||||
case "$HOST_ARCH" in
|
case "$HOST_ARCH" in
|
||||||
x86_64) printf '%s\n' x64 ;;
|
x86_64) printf '%s\n' x64 ;;
|
||||||
@@ -639,7 +869,7 @@ select_targets() {
|
|||||||
esac
|
esac
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Unknown --arch '${ARCH_OVERRIDE}'. Use x64|arm64|all." >&2
|
echo "Unknown --arch '${ARCH_OVERRIDE}'. Use x64|arm64|riscv64|ppc64le|loong64|all." >&2
|
||||||
return 1
|
return 1
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
@@ -658,6 +888,8 @@ build_one_target() {
|
|||||||
archdir="${meta[2]}"
|
archdir="${meta[2]}"
|
||||||
|
|
||||||
echo "[*] Building for target: $short (RID=$rid, RPM --target $rpm_target)"
|
echo "[*] Building for target: $short (RID=$rid, RPM --target $rpm_target)"
|
||||||
|
prepare_cross_rpm_tools "$short"
|
||||||
|
prepare_dotnet_sdk "$short" || die "Failed to prepare .NET SDK for $short."
|
||||||
publish_binary "$rid"
|
publish_binary "$rid"
|
||||||
package_binary "$short" "$rid" "$rpm_target" "$archdir"
|
package_binary "$short" "$rid" "$rpm_target" "$archdir"
|
||||||
}
|
}
|
||||||
@@ -666,7 +898,7 @@ print_summary() {
|
|||||||
if [[ "$BUILT_ALL" -eq 1 ]]; then
|
if [[ "$BUILT_ALL" -eq 1 ]]; then
|
||||||
local rp=""
|
local rp=""
|
||||||
echo ""
|
echo ""
|
||||||
echo "================ Build Summary (both architectures) ================"
|
echo "================ Build Summary (all architectures) ================"
|
||||||
if [[ "${#BUILT_RPMS[@]}" -gt 0 ]]; then
|
if [[ "${#BUILT_RPMS[@]}" -gt 0 ]]; then
|
||||||
for rp in "${BUILT_RPMS[@]}"; do
|
for rp in "${BUILT_RPMS[@]}"; do
|
||||||
echo "$rp"
|
echo "$rp"
|
||||||
@@ -688,6 +920,8 @@ main() {
|
|||||||
prepare_workspace
|
prepare_workspace
|
||||||
resolve_version
|
resolve_version
|
||||||
|
|
||||||
|
download_nuget_packages || die "Failed to download prebuilt NuGet packages."
|
||||||
|
|
||||||
mapfile -t targets < <(select_targets)
|
mapfile -t targets < <(select_targets)
|
||||||
[[ "${ARCH_OVERRIDE:-}" == "all" ]] && BUILT_ALL=1 || BUILT_ALL=0
|
[[ "${ARCH_OVERRIDE:-}" == "all" ]] && BUILT_ALL=1 || BUILT_ALL=0
|
||||||
|
|
||||||
@@ -695,6 +929,9 @@ main() {
|
|||||||
build_one_target "$arch"
|
build_one_target "$arch"
|
||||||
done
|
done
|
||||||
|
|
||||||
|
rm -rf "${NUGET_CACHE_DIR:-}" || true
|
||||||
|
rm -rf "${DOTNET_SDK_DIR:-}" || true
|
||||||
|
|
||||||
print_summary
|
print_summary
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,16 +5,31 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<EmbeddedResource Update="Resx\Resource.resx">
|
|
||||||
<Generator>ResXFileCodeGenerator</Generator>
|
|
||||||
<LastGenOutput>Resource.Designer.cs</LastGenOutput>
|
|
||||||
</EmbeddedResource>
|
|
||||||
|
|
||||||
<Compile Update="Resx\Resource.Designer.cs">
|
<Compile Update="Resx\Resource.Designer.cs">
|
||||||
<DesignTime>True</DesignTime>
|
<DesignTime>True</DesignTime>
|
||||||
<AutoGen>True</AutoGen>
|
<AutoGen>True</AutoGen>
|
||||||
<DependentUpon>Resource.resx</DependentUpon>
|
<DependentUpon>Resource.resx</DependentUpon>
|
||||||
</Compile>
|
</Compile>
|
||||||
|
<EmbeddedResource Update="Resx\Resource.resx">
|
||||||
|
<SubType>Designer</SubType>
|
||||||
|
<LastGenOutput>Resource.Designer.cs</LastGenOutput>
|
||||||
|
<Generator>ResXFileCodeGenerator</Generator>
|
||||||
|
</EmbeddedResource>
|
||||||
|
<EmbeddedResource Update="Resx\ResUI.zh-Hans.resx">
|
||||||
|
<SubType>Designer</SubType>
|
||||||
|
<Generator>ResXFileCodeGenerator</Generator>
|
||||||
|
</EmbeddedResource>
|
||||||
|
<EmbeddedResource Update="Resx\ResUI.zh-Hant.resx">
|
||||||
|
<SubType>Designer</SubType>
|
||||||
|
<Generator>ResXFileCodeGenerator</Generator>
|
||||||
|
</EmbeddedResource>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<Target Name="RemoveAotSatelliteOutputs" AfterTargets="ComputeFilesToPublish" Condition="'$(PublishAot)' == 'true'">
|
||||||
|
<ItemGroup>
|
||||||
|
<ResolvedFileToPublish Remove="@(ResolvedFileToPublish)"
|
||||||
|
Condition="$([System.String]::Copy('%(ResolvedFileToPublish.RelativePath)').Contains('resources.dll'))" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Target>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
+1
-1
@@ -19,7 +19,7 @@ namespace AmazTool.Resx {
|
|||||||
// 类通过类似于 ResGen 或 Visual Studio 的工具自动生成的。
|
// 类通过类似于 ResGen 或 Visual Studio 的工具自动生成的。
|
||||||
// 若要添加或移除成员,请编辑 .ResX 文件,然后重新运行 ResGen
|
// 若要添加或移除成员,请编辑 .ResX 文件,然后重新运行 ResGen
|
||||||
// (以 /str 作为命令选项),或重新生成 VS 项目。
|
// (以 /str 作为命令选项),或重新生成 VS 项目。
|
||||||
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")]
|
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")]
|
||||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||||
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
|
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
|
||||||
internal class Resource {
|
internal class Resource {
|
||||||
|
|||||||
@@ -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.24.9</Version>
|
<Version>7.25.6</Version>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
@@ -29,4 +29,14 @@
|
|||||||
<PublishSingleFile>true</PublishSingleFile>
|
<PublishSingleFile>true</PublishSingleFile>
|
||||||
<PublishReadyToRun>false</PublishReadyToRun>
|
<PublishReadyToRun>false</PublishReadyToRun>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<IsAot Condition="'$(IsAot)' == ''">false</IsAot>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup Condition="'$(IsAot)' == 'true'">
|
||||||
|
<PublishAot>true</PublishAot>
|
||||||
|
<PublishTrimmed>true</PublishTrimmed>
|
||||||
|
<DefineConstants>$(DefineConstants);V2RAYN_IS_AOT</DefineConstants>
|
||||||
|
</PropertyGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -6,32 +6,31 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageVersion Include="Avalonia.AvaloniaEdit" Version="12.0.0" />
|
<PackageVersion Include="Avalonia.AvaloniaEdit" Version="12.0.0" />
|
||||||
<PackageVersion Include="Avalonia.Controls.DataGrid" Version="12.1.2" />
|
<PackageVersion Include="Avalonia.Desktop" Version="12.1.5" />
|
||||||
<PackageVersion Include="Avalonia.Desktop" Version="12.1.1" />
|
|
||||||
<PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3" />
|
<PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3" />
|
||||||
<PackageVersion Include="DialogHost.Avalonia" Version="0.12.3" />
|
<PackageVersion Include="DialogHost.Avalonia" Version="0.12.3" />
|
||||||
<PackageVersion Include="IPNetwork2" Version="4.3.0" />
|
<PackageVersion Include="IPNetwork2" Version="5.0.1" />
|
||||||
<PackageVersion Include="ReactiveUI.Avalonia" Version="12.1.1" />
|
<PackageVersion Include="ProDataGrid" Version="12.1.5" />
|
||||||
|
<PackageVersion Include="ReactiveUI.Avalonia" Version="12.1.6" />
|
||||||
<PackageVersion Include="CliWrap" Version="3.10.5" />
|
<PackageVersion Include="CliWrap" Version="3.10.5" />
|
||||||
<PackageVersion Include="Downloader" Version="5.9.5" />
|
<PackageVersion Include="Downloader" Version="6.0.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.4.0" />
|
||||||
<PackageVersion Include="QRCoder" Version="1.8.0" />
|
<PackageVersion Include="QRCoder" Version="1.8.0" />
|
||||||
<PackageVersion Include="ReactiveUI" Version="24.1.0" />
|
<PackageVersion Include="ReactiveUI" Version="26.0.1" />
|
||||||
<PackageVersion Include="ReactiveUI.SourceGenerators" Version="3.2.0" />
|
<PackageVersion Include="ReactiveUI.WPF" Version="26.0.1" />
|
||||||
<PackageVersion Include="ReactiveUI.WPF" Version="24.1.0" />
|
|
||||||
<PackageVersion Include="Semi.Avalonia" Version="12.1.0.1" />
|
<PackageVersion Include="Semi.Avalonia" Version="12.1.0.1" />
|
||||||
<PackageVersion Include="Semi.Avalonia.AvaloniaEdit" Version="12.0.0" />
|
<PackageVersion Include="Semi.Avalonia.AvaloniaEdit" Version="12.0.0" />
|
||||||
<PackageVersion Include="Semi.Avalonia.DataGrid" Version="12.1.0.1" />
|
<PackageVersion Include="Semi.Avalonia.DataGrid" Version="12.1.0.1" />
|
||||||
<PackageVersion Include="NLog" Version="6.2.0" />
|
<PackageVersion Include="NLog" Version="6.2.2" />
|
||||||
<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.54.1" />
|
||||||
<PackageVersion Include="TaskScheduler" Version="2.12.2" />
|
<PackageVersion Include="TUnit" Version="1.81.0" />
|
||||||
<PackageVersion Include="TUnit" Version="1.65.68" />
|
<PackageVersion Include="TUnit.Assertions.Should" Version="1.72.16-beta" />
|
||||||
<PackageVersion Include="TUnit.Assertions.Should" Version="1.65.38-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" />
|
||||||
|
<PackageVersion Include="HarfBuzzSharp.NativeAssets.Linux" Version="8.3.1.5" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
+1
-1
Submodule v2rayN/GlobalHotKeys updated: 162d401dfe...e507a76804
@@ -0,0 +1,82 @@
|
|||||||
|
namespace ServiceLib.Tests.Common;
|
||||||
|
|
||||||
|
public class JsonUtilsMetadataTests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
[Arguments(false, false)]
|
||||||
|
[Arguments(false, true)]
|
||||||
|
[Arguments(true, false)]
|
||||||
|
[Arguments(true, true)]
|
||||||
|
public async Task Serialize_PreservesIndentationNullsAndUnicode(bool indented, bool nullValue)
|
||||||
|
{
|
||||||
|
var item = new WebDavItem { Url = "https://example.com/русский", UserName = "tester" };
|
||||||
|
|
||||||
|
var json = JsonUtils.Serialize(item, indented, nullValue);
|
||||||
|
var node = JsonUtils.ParseJson(json)!.AsObject();
|
||||||
|
|
||||||
|
await node["Url"]!.GetValue<string>().Should().BeEqualTo(item.Url);
|
||||||
|
await json.Contains("русский").Should().BeTrue();
|
||||||
|
await json.Contains(Environment.NewLine).Should().BeEqualTo(indented);
|
||||||
|
await node.ContainsKey("Password").Should().BeEqualTo(nullValue);
|
||||||
|
await node.ContainsKey("DirName").Should().BeEqualTo(nullValue);
|
||||||
|
await JsonUtils.Deserialize<WebDavItem>(json)!.UserName.Should().BeEqualTo("tester");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Deserialize_AcceptsCommentsAndCaseInsensitiveNames()
|
||||||
|
{
|
||||||
|
const string json = "{/* existing configuration */ \"uRl\":\"https://example.com\",\"USERNAME\":\"tester\"}";
|
||||||
|
var item = JsonUtils.Deserialize<WebDavItem>(json)!;
|
||||||
|
|
||||||
|
await item.Url.Should().BeEqualTo("https://example.com");
|
||||||
|
await item.UserName.Should().BeEqualTo("tester");
|
||||||
|
await JsonUtils.Deserialize<WebDavItem>("invalid").Should().BeNull();
|
||||||
|
await JsonUtils.Deserialize<WebDavItem>(" ").Should().BeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task NodeAndCamelCaseSerialization_UseTheRequestedOptions()
|
||||||
|
{
|
||||||
|
var item = new WebDavItem { Url = "https://example.com" };
|
||||||
|
var node = JsonUtils.SerializeToNode(item, nullValue: true)!.AsObject();
|
||||||
|
var camelCase = JsonUtils.ParseJson(JsonUtils.SerializeCamelCase(item))!.AsObject();
|
||||||
|
|
||||||
|
await node.ContainsKey("Password").Should().BeTrue();
|
||||||
|
await JsonUtils.SerializeToNode(item)!.AsObject().ContainsKey("Password").Should().BeFalse();
|
||||||
|
await camelCase["url"]!.GetValue<string>().Should().BeEqualTo(item.Url);
|
||||||
|
await camelCase.ContainsKey("Url").Should().BeFalse();
|
||||||
|
await camelCase.ContainsKey("password").Should().BeFalse();
|
||||||
|
|
||||||
|
var values = JsonUtils.SerializeToArray(["first", "second"])!;
|
||||||
|
await values.Count.Should().BeEqualTo(2);
|
||||||
|
await values[0]!.GetValue<string>().Should().BeEqualTo("first");
|
||||||
|
await values[1]!.GetValue<string>().Should().BeEqualTo("second");
|
||||||
|
await JsonUtils.SerializeToArray(null).Should().BeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task DeepCopy_KeepsProfileDataAndCreatesAnIndependentModel()
|
||||||
|
{
|
||||||
|
var item = new ProfileItem { IndexId = "profile", Address = "example.com", Port = 443, Remarks = "Original" };
|
||||||
|
var copy = JsonUtils.DeepCopy(item)!;
|
||||||
|
|
||||||
|
await copy.IndexId.Should().BeEqualTo(item.IndexId);
|
||||||
|
await copy.Address.Should().BeEqualTo(item.Address);
|
||||||
|
await copy.Port.Should().BeEqualTo(443);
|
||||||
|
copy.Remarks = "Changed";
|
||||||
|
await item.Remarks.Should().BeEqualTo("Original");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task UnsupportedTypes_PreserveTheExistingFailureResults()
|
||||||
|
{
|
||||||
|
await JsonUtils.Serialize(new UnsupportedValue()).Should().BeEqualTo(string.Empty);
|
||||||
|
await JsonUtils.SerializeCamelCase(new UnsupportedValue()).Should().BeEqualTo(string.Empty);
|
||||||
|
await JsonUtils.Deserialize<UnsupportedValue>("{\"Name\":\"value\"}").Should().BeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class UnsupportedValue
|
||||||
|
{
|
||||||
|
public string Name { get; set; } = "value";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -205,7 +200,8 @@ internal static class CoreConfigTestFactory
|
|||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static CoreConfigContext CreateContext(Config config, ProfileItem node, ECoreType runCoreType)
|
public static CoreConfigContext CreateContext(Config config, ProfileItem node, ECoreType runCoreType,
|
||||||
|
bool hasGlobalIPv6Address = true)
|
||||||
{
|
{
|
||||||
return new CoreConfigContext
|
return new CoreConfigContext
|
||||||
{
|
{
|
||||||
@@ -226,6 +222,7 @@ internal static class CoreConfigTestFactory
|
|||||||
FullConfigTemplate = null,
|
FullConfigTemplate = null,
|
||||||
IsTunEnabled = config.TunModeItem.EnableTun,
|
IsTunEnabled = config.TunModeItem.EnableTun,
|
||||||
ProtectDomainList = [],
|
ProtectDomainList = [],
|
||||||
|
HasGlobalIPv6Address = hasGlobalIPv6Address,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
namespace ServiceLib.Tests.CoreConfig;
|
||||||
|
|
||||||
|
public class DualStackFakeIPTests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
[Arguments(null, "fc00::/18", 65535L)]
|
||||||
|
[Arguments(" 2001:2::/48 ", "2001:2::/48", 65535L)]
|
||||||
|
[Arguments("fc00::/120", "fc00::/120", 255L)]
|
||||||
|
[Arguments("fc00::/127", "fc00::/127", 1L)]
|
||||||
|
public async Task IPv6Range_ShouldUseAnXrayCompatiblePoolSize(string? input, string expectedRange, long expectedSize)
|
||||||
|
{
|
||||||
|
var valid = FakeIPUtils.TryParseIPv6Range(input, out var range, out var size);
|
||||||
|
|
||||||
|
await valid.Should().BeTrue();
|
||||||
|
await range.Should().BeEqualTo(expectedRange);
|
||||||
|
await size.Should().BeEqualTo(expectedSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
[Arguments("198.18.0.0/15")]
|
||||||
|
[Arguments("not-a-network")]
|
||||||
|
[Arguments("fc00::/128")]
|
||||||
|
public async Task IPv6Range_ShouldRejectInvalidOrSingleAddressPools(string input)
|
||||||
|
{
|
||||||
|
await FakeIPUtils.TryParseIPv6Range(input, out _, out _).Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task OldDnsSettings_ShouldKeepIPv6FakeIPDisabled()
|
||||||
|
{
|
||||||
|
var settings = JsonUtils.Deserialize<SimpleDNSItem>("""
|
||||||
|
{ "FakeIP": true, "FakeIPRange": "198.18.0.0/15" }
|
||||||
|
""")!;
|
||||||
|
|
||||||
|
await (settings.FakeIPv6 == true).Should().BeFalse();
|
||||||
|
await settings.FakeIPRange.Should().BeEqualTo("198.18.0.0/15");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task DnsSettings_ShouldRoundTripBothAddressRanges()
|
||||||
|
{
|
||||||
|
var settings = new SimpleDNSItem
|
||||||
|
{
|
||||||
|
FakeIP = true,
|
||||||
|
FakeIPRange = "198.18.0.0/15",
|
||||||
|
FakeIPv6 = true,
|
||||||
|
FakeIPv6Range = "2001:2::/48",
|
||||||
|
};
|
||||||
|
|
||||||
|
var restored = JsonUtils.Deserialize<SimpleDNSItem>(JsonUtils.Serialize(settings))!;
|
||||||
|
|
||||||
|
await restored.FakeIP.Should().BeEqualTo(true);
|
||||||
|
await restored.FakeIPRange.Should().BeEqualTo(settings.FakeIPRange);
|
||||||
|
await restored.FakeIPv6.Should().BeEqualTo(true);
|
||||||
|
await restored.FakeIPv6Range.Should().BeEqualTo(settings.FakeIPv6Range);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task AppConfig_ShouldRoundTripIPv6Settings()
|
||||||
|
{
|
||||||
|
var config = CoreConfigTestFactory.CreateConfig();
|
||||||
|
config.SimpleDNSItem.FakeIPv6 = true;
|
||||||
|
config.SimpleDNSItem.FakeIPv6Range = "2001:2::/48";
|
||||||
|
|
||||||
|
var restored = JsonUtils.Deserialize<Config>(JsonUtils.Serialize(config))!;
|
||||||
|
|
||||||
|
await restored.Should().NotBeNull();
|
||||||
|
await restored.SimpleDNSItem.FakeIPv6.Should().BeEqualTo(true);
|
||||||
|
await restored.SimpleDNSItem.FakeIPv6Range.Should().BeEqualTo("2001:2::/48");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
[Arguments(false)]
|
||||||
|
[Arguments(true)]
|
||||||
|
public async Task Xray_ShouldRoundTripSingleAndDualPools(bool enableIPv6)
|
||||||
|
{
|
||||||
|
var context = CreateContext(ECoreType.Xray, enableIPv6, false);
|
||||||
|
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
|
await result.Success.Should().BeTrue();
|
||||||
|
var restored = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
|
||||||
|
await restored.Should().NotBeNull();
|
||||||
|
var roundTrip = JsonUtils.ParseJson(JsonUtils.Serialize(restored))!;
|
||||||
|
var original = JsonUtils.ParseJson(result.Data!.ToString())!;
|
||||||
|
await JsonNode.DeepEquals(original["fakedns"], roundTrip["fakedns"]).Should().BeTrue();
|
||||||
|
await (restored.fakedns is JsonArray).Should().BeEqualTo(enableIPv6);
|
||||||
|
await restored.fakedns.Should().NotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
[Arguments(false, false)]
|
||||||
|
[Arguments(true, false)]
|
||||||
|
[Arguments(true, true)]
|
||||||
|
public async Task Xray_ShouldAddIPv6OnlyWhenEnabledAndAAAAAllowed(bool enableIPv6, bool blockAAAA)
|
||||||
|
{
|
||||||
|
var context = CreateContext(ECoreType.Xray, enableIPv6, blockAAAA);
|
||||||
|
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
|
await result.Success.Should().BeTrue();
|
||||||
|
var root = JsonUtils.ParseJson(result.Data!.ToString())!;
|
||||||
|
var dualStack = enableIPv6 && !blockAAAA;
|
||||||
|
await (root["fakedns"] is JsonArray).Should().BeEqualTo(dualStack);
|
||||||
|
var pool = dualStack ? root["fakedns"]![0]! : root["fakedns"]!;
|
||||||
|
await pool["ipPool"]!.GetValue<string>().Should().BeEqualTo("198.18.0.0/15");
|
||||||
|
if (dualStack)
|
||||||
|
{
|
||||||
|
await root["fakedns"]!.AsArray().Count.Should().BeEqualTo(2);
|
||||||
|
await root["fakedns"]![1]!["ipPool"]!.GetValue<string>().Should().BeEqualTo("2001:2::/48");
|
||||||
|
await root["fakedns"]![1]!["poolSize"]!.GetValue<long>().Should().BeEqualTo(65535L);
|
||||||
|
}
|
||||||
|
if (blockAAAA)
|
||||||
|
{
|
||||||
|
await root["dns"]!["queryStrategy"]!.GetValue<string>().Should().BeEqualTo("UseIPv4");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
[Arguments(false, false)]
|
||||||
|
[Arguments(true, false)]
|
||||||
|
[Arguments(true, true)]
|
||||||
|
public async Task Singbox_ShouldAddIPv6OnlyWhenEnabledAndAAAAAllowed(bool enableIPv6, bool blockAAAA)
|
||||||
|
{
|
||||||
|
var context = CreateContext(ECoreType.sing_box, enableIPv6, blockAAAA);
|
||||||
|
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
|
await result.Success.Should().BeTrue();
|
||||||
|
var config = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!;
|
||||||
|
var fakeDns = config.dns.servers.Single(server => server.type == "fakeip");
|
||||||
|
|
||||||
|
await fakeDns.inet4_range.Should().BeEqualTo("198.18.0.0/15");
|
||||||
|
await fakeDns.inet6_range.Should().BeEqualTo(enableIPv6 && !blockAAAA ? "2001:2::/48" : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task DisabledFakeIP_ShouldNotCreateEitherPool()
|
||||||
|
{
|
||||||
|
foreach (var coreType in new[] { ECoreType.Xray, ECoreType.sing_box })
|
||||||
|
{
|
||||||
|
var context = CreateContext(coreType, true, false);
|
||||||
|
context.AppConfig.SimpleDNSItem.FakeIP = false;
|
||||||
|
var result = coreType == ECoreType.Xray
|
||||||
|
? new CoreConfigV2rayService(context).GenerateClientConfigContent()
|
||||||
|
: new CoreConfigSingboxService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
|
await result.Success.Should().BeTrue();
|
||||||
|
var root = JsonUtils.ParseJson(result.Data!.ToString())!;
|
||||||
|
await root["fakedns"].Should().BeNull();
|
||||||
|
await root["dns"]!["servers"]!.AsArray()
|
||||||
|
.Any(server => server is JsonObject obj && obj["type"]?.GetValue<string>() == "fakeip")
|
||||||
|
.Should().BeFalse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CoreConfigContext CreateContext(ECoreType coreType, bool enableIPv6, bool blockAAAA)
|
||||||
|
{
|
||||||
|
var config = CoreConfigTestFactory.CreateConfig(coreType);
|
||||||
|
config.SimpleDNSItem.FakeIP = true;
|
||||||
|
config.SimpleDNSItem.FakeIPRange = "198.18.0.0/15";
|
||||||
|
config.SimpleDNSItem.FakeIPv6 = enableIPv6;
|
||||||
|
config.SimpleDNSItem.FakeIPv6Range = "2001:2::/48";
|
||||||
|
config.SimpleDNSItem.BlockAAAAQuery = blockAAAA;
|
||||||
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
|
var node = CoreConfigTestFactory.CreateVmessNode(coreType);
|
||||||
|
return CoreConfigTestFactory.CreateContext(config, node, coreType) with
|
||||||
|
{
|
||||||
|
RoutingItem = new RoutingItem
|
||||||
|
{
|
||||||
|
Id = "fakeip-test",
|
||||||
|
Remarks = "FakeIP test",
|
||||||
|
DomainStrategy = Global.AsIs,
|
||||||
|
DomainStrategy4Singbox = string.Empty,
|
||||||
|
RuleSet = JsonUtils.Serialize(new List<RulesItem>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
RuleType = ERuleType.DNS,
|
||||||
|
OutboundTag = Global.ProxyTag,
|
||||||
|
Domain = ["full:example.com"],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,6 +46,72 @@ public class CoreConfigSingboxServiceTests
|
|||||||
await cfg.inbounds.Should().Contain(i => i.type == "tun");
|
await cfg.inbounds.Should().Contain(i => i.type == "tun");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
[Arguments(null)]
|
||||||
|
[Arguments("Default")]
|
||||||
|
public async Task GenerateClientConfigContent_TunDnsModeDefault_ShouldNotIncludeDnsMode(string? dnsMode)
|
||||||
|
{
|
||||||
|
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
|
||||||
|
config.TunModeItem.EnableTun = true;
|
||||||
|
config.TunModeItem.DnsMode = dnsMode;
|
||||||
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
|
var node = CoreConfigTestFactory.CreateSocksNode(ECoreType.sing_box);
|
||||||
|
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.sing_box) with
|
||||||
|
{
|
||||||
|
IsTunEnabled = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
|
await result.Success.Should().BeTrue().Because($"ret msg: {result.Msg}");
|
||||||
|
await result.Data!.ToString().Should().NotContain("\"dns_mode\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GenerateClientConfigContent_TunDnsModeDisabled_ShouldIncludeDnsMode()
|
||||||
|
{
|
||||||
|
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
|
||||||
|
config.TunModeItem.EnableTun = true;
|
||||||
|
config.TunModeItem.DnsMode = "disabled";
|
||||||
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
|
var node = CoreConfigTestFactory.CreateSocksNode(ECoreType.sing_box);
|
||||||
|
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.sing_box) with
|
||||||
|
{
|
||||||
|
IsTunEnabled = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
|
await result.Success.Should().BeTrue().Because($"ret msg: {result.Msg}");
|
||||||
|
var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!;
|
||||||
|
var tun = cfg.inbounds.First(i => i.type == "tun");
|
||||||
|
await tun.dns_mode.Should().BeEqualTo("disabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
[Arguments(true)]
|
||||||
|
[Arguments(false)]
|
||||||
|
public async Task GenerateClientConfigContent_TunDnsModeDisabled_ShouldNotIncludeGlobalDnsHijackRule(bool sniffingEnabled)
|
||||||
|
{
|
||||||
|
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
|
||||||
|
config.TunModeItem.EnableTun = true;
|
||||||
|
config.TunModeItem.DnsMode = "disabled";
|
||||||
|
config.Inbound.First().SniffingEnabled = sniffingEnabled;
|
||||||
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
|
var node = CoreConfigTestFactory.CreateSocksNode(ECoreType.sing_box);
|
||||||
|
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.sing_box) with
|
||||||
|
{
|
||||||
|
IsTunEnabled = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
|
await result.Success.Should().BeTrue().Because($"ret msg: {result.Msg}");
|
||||||
|
var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!;
|
||||||
|
await cfg.route.rules.Any(
|
||||||
|
r => r.action == "hijack-dns" && r.process_path == null).Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public async Task GenerateClientConfigContent_TunEnabled_ShouldKeepEmbeddedTunRules()
|
public async Task GenerateClientConfigContent_TunEnabled_ShouldKeepEmbeddedTunRules()
|
||||||
{
|
{
|
||||||
@@ -378,50 +444,6 @@ public class CoreConfigSingboxServiceTests
|
|||||||
await hasRouteRule.Should().BeTrue();
|
await hasRouteRule.Should().BeTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
|
||||||
public async Task GenerateClientConfigContent_DirectExpectedIPs_ShouldApplyGeoipAndCidrToDirectDnsRule()
|
|
||||||
{
|
|
||||||
var config = CoreConfigTestFactory.CreateConfigWithDirectExpectedIPs(
|
|
||||||
ECoreType.sing_box,
|
|
||||||
"192.168.0.0/16,geoip:cn");
|
|
||||||
CoreConfigTestFactory.BindAppManagerConfig(config);
|
|
||||||
|
|
||||||
var node = CoreConfigTestFactory.CreateSocksNode(ECoreType.sing_box, "n-main", "main");
|
|
||||||
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.sing_box) with
|
|
||||||
{
|
|
||||||
RoutingItem = new RoutingItem
|
|
||||||
{
|
|
||||||
Id = "r-dns-direct-expected",
|
|
||||||
Remarks = "dns-direct-expected",
|
|
||||||
RuleSet = JsonUtils.Serialize(new List<RulesItem>
|
|
||||||
{
|
|
||||||
new()
|
|
||||||
{
|
|
||||||
Enabled = true,
|
|
||||||
RuleType = ERuleType.DNS,
|
|
||||||
OutboundTag = Global.DirectTag,
|
|
||||||
Domain = ["geosite:cn"],
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
DomainStrategy = Global.AsIs,
|
|
||||||
DomainStrategy4Singbox = string.Empty,
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
|
|
||||||
|
|
||||||
await result.Success.Should().BeTrue().Because($"ret msg: {result.Msg}");
|
|
||||||
var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!;
|
|
||||||
|
|
||||||
var hasExpectedRule = cfg.dns.rules?.Any(r =>
|
|
||||||
r.server == Global.SingboxDirectDNSTag
|
|
||||||
&& r.ip_cidr?.Contains("192.168.0.0/16") == true
|
|
||||||
&& r.rule_set?.Contains("geosite-cn") == true
|
|
||||||
&& r.rule_set?.Contains("geoip-cn") == true) ?? false;
|
|
||||||
|
|
||||||
await hasExpectedRule.Should().BeTrue();
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public async Task GenerateClientConfigContent_BootstrapDNS_ShouldConfigurePureIPResolver()
|
public async Task GenerateClientConfigContent_BootstrapDNS_ShouldConfigurePureIPResolver()
|
||||||
{
|
{
|
||||||
@@ -544,14 +566,18 @@ public class CoreConfigSingboxServiceTests
|
|||||||
Remarks = "dns-direct-variant",
|
Remarks = "dns-direct-variant",
|
||||||
RuleSet = JsonUtils.Serialize(new List<RulesItem>
|
RuleSet = JsonUtils.Serialize(new List<RulesItem>
|
||||||
{
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Enabled = true, RuleType = ERuleType.DNS, OutboundTag = Global.ProxyTag, Domain = ["geosite:google"],
|
||||||
|
},
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
Enabled = true, RuleType = ERuleType.DNS, OutboundTag = Global.DirectTag, Domain = [domainTag],
|
Enabled = true, RuleType = ERuleType.DNS, OutboundTag = Global.DirectTag, Domain = [domainTag],
|
||||||
}
|
},
|
||||||
}),
|
}),
|
||||||
DomainStrategy = Global.AsIs,
|
DomainStrategy = Global.AsIs,
|
||||||
DomainStrategy4Singbox = string.Empty,
|
DomainStrategy4Singbox = string.Empty,
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
|
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
|
||||||
@@ -559,11 +585,14 @@ public class CoreConfigSingboxServiceTests
|
|||||||
await result.Success.Should().BeTrue().Because($"ret msg: {result.Msg}");
|
await result.Success.Should().BeTrue().Because($"ret msg: {result.Msg}");
|
||||||
var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!;
|
var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!;
|
||||||
|
|
||||||
var hasExpectedRule = cfg.dns.rules?.Any(r =>
|
var hasExpectedRule = (cfg.dns.rules?.Any(r =>
|
||||||
|
r.ip_cidr?.Contains("192.168.0.0/16") == true
|
||||||
|
&& r.action == "respond"
|
||||||
|
&& r.rule_set?.Contains("geoip-cn") == true) ?? false)
|
||||||
|
&& (cfg.dns.rules?.Any(r =>
|
||||||
r.server == Global.SingboxDirectDNSTag
|
r.server == Global.SingboxDirectDNSTag
|
||||||
&& r.ip_cidr?.Contains("192.168.0.0/16") == true
|
|
||||||
&& r.rule_set?.Contains(expectedRuleSetTag) == true
|
&& r.rule_set?.Contains(expectedRuleSetTag) == true
|
||||||
&& r.rule_set?.Contains("geoip-cn") == true) ?? false;
|
&& r.action == "evaluate") ?? false);
|
||||||
await hasExpectedRule.Should().BeTrue();
|
await hasExpectedRule.Should().BeTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -632,6 +661,60 @@ public class CoreConfigSingboxServiceTests
|
|||||||
await cfg.dns.rules.Should().Contain(r => r.clash_mode == nameof(ERuleMode.Direct));
|
await cfg.dns.rules.Should().Contain(r => r.clash_mode == nameof(ERuleMode.Direct));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
[Arguments(true)]
|
||||||
|
[Arguments(false)]
|
||||||
|
public async Task GenerateClientConfigContent_MultiDnsServers_ShouldIncludeAllServers(bool parallelQuery)
|
||||||
|
{
|
||||||
|
var config =
|
||||||
|
CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
|
||||||
|
config.SimpleDNSItem.ParallelQuery = parallelQuery;
|
||||||
|
config.SimpleDNSItem.DirectDNS = "1.1.1.1,2.2.2.2";
|
||||||
|
config.SimpleDNSItem.RemoteDNS = "3.3.3.3,4.4.4.4";
|
||||||
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
|
|
||||||
|
var node = CoreConfigTestFactory.CreateSocksNode(ECoreType.sing_box, "n-main", "main");
|
||||||
|
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.sing_box) with
|
||||||
|
{
|
||||||
|
RoutingItem = new RoutingItem
|
||||||
|
{
|
||||||
|
Id = "r-dns-direct-variant",
|
||||||
|
Remarks = "dns-direct-variant",
|
||||||
|
RuleSet = JsonUtils.Serialize(new List<RulesItem>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
RuleType = ERuleType.DNS,
|
||||||
|
OutboundTag = Global.ProxyTag,
|
||||||
|
Domain = ["geosite:google"],
|
||||||
|
},
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
RuleType = ERuleType.DNS,
|
||||||
|
OutboundTag = Global.DirectTag,
|
||||||
|
Domain = ["geosite:cn"],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
DomainStrategy = Global.AsIs,
|
||||||
|
DomainStrategy4Singbox = string.Empty,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
|
await result.Success.Should().BeTrue().Because($"ret msg: {result.Msg}");
|
||||||
|
var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!;
|
||||||
|
|
||||||
|
await cfg.dns.servers.Where(s => s.tag.StartsWith(Global.SingboxDirectDNSTagPrefix)).Should().HaveCount(2);
|
||||||
|
await cfg.dns.servers.Where(s => s.tag.StartsWith(Global.SingboxRemoteDNSTagPrefix)).Should().HaveCount(2);
|
||||||
|
if (parallelQuery)
|
||||||
|
{
|
||||||
|
await cfg.dns.rules.Should().Contain(r => r.race == true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public async Task GenerateClientConfigContent_Hysteria2Realm_ShouldEmitHttpsServerUrl()
|
public async Task GenerateClientConfigContent_Hysteria2Realm_ShouldEmitHttpsServerUrl()
|
||||||
{
|
{
|
||||||
@@ -699,7 +782,6 @@ public class CoreConfigSingboxServiceTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public async Task GenerateClientConfigContent_CustomOutbound_ShouldReplaceWithUserCustomOutboundJson()
|
public async Task GenerateClientConfigContent_CustomOutbound_ShouldReplaceWithUserCustomOutboundJson()
|
||||||
{
|
{
|
||||||
@@ -735,4 +817,29 @@ public class CoreConfigSingboxServiceTests
|
|||||||
await proxyOutbound.method.Should().BeEqualTo("aes-128-gcm");
|
await proxyOutbound.method.Should().BeEqualTo("aes-128-gcm");
|
||||||
await proxyOutbound.password.Should().BeEqualTo("custom_password");
|
await proxyOutbound.password.Should().BeEqualTo("custom_password");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
[Arguments("/ws?ed=2048", "/ws")]
|
||||||
|
[Arguments("/ws?ed=2048&key=value", "/ws?key=value")]
|
||||||
|
[Arguments("/ws?key=value&ed=2048", "/ws?key=value")]
|
||||||
|
[Arguments("/ws?a=1&ed=2048&b=2", "/ws?a=1&b=2")]
|
||||||
|
public async Task GenerateClientConfigContent_WsEarlyDataInPath_ShouldKeepRemainingQuery(string path, string expectedPath)
|
||||||
|
{
|
||||||
|
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
|
||||||
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
|
var node = CoreConfigTestFactory.CreateVmessNode(ECoreType.sing_box);
|
||||||
|
node.Network = nameof(ETransport.ws);
|
||||||
|
node.SetTransportExtra(node.GetTransportExtra() with { Path = path });
|
||||||
|
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.sing_box);
|
||||||
|
|
||||||
|
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
|
await result.Success.Should().BeTrue().Because($"ret msg: {result.Msg}");
|
||||||
|
var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!;
|
||||||
|
var transport = cfg.outbounds.First(o => o.tag == Global.ProxyTag).transport;
|
||||||
|
await transport.Should().NotBeNull();
|
||||||
|
await transport!.max_early_data.Should().BeEqualTo(2048);
|
||||||
|
await transport.early_data_header_name.Should().BeEqualTo("Sec-WebSocket-Protocol");
|
||||||
|
await transport.path.Should().BeEqualTo(expectedPath);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
namespace ServiceLib.Tests.CoreConfig.V2ray;
|
||||||
|
|
||||||
|
public class CoreConfigV2rayIPNetworkTests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
[Arguments("0.0.0.0/0", "::/0")]
|
||||||
|
[Arguments("::/0", "0.0.0.0/0")]
|
||||||
|
[Arguments("0.0.0.0/1", "128.0.0.0/1,::/0")]
|
||||||
|
[Arguments("::/1", "0.0.0.0/0,8000::/1")]
|
||||||
|
public async Task GenerateClientConfigContent_TunExclusion_ShouldPreserveRemainingRoutes(
|
||||||
|
string excludedNetwork, string expectedRoutes)
|
||||||
|
{
|
||||||
|
var config = CoreConfigTestFactory.CreateConfigWithTun(ECoreType.Xray, true);
|
||||||
|
config.TunModeItem.RouteExcludeAddress = [excludedNetwork];
|
||||||
|
var generated = await GenerateConfig(config);
|
||||||
|
|
||||||
|
var routes = generated.inbounds.Single(i => i.protocol == "tun").settings.autoSystemRoutingTable;
|
||||||
|
await routes.Should().BeEquivalentTo(expectedRoutes.Split(','), StringComparer.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GenerateClientConfigContent_TunExclusions_ShouldHandleOverlappingAndDuplicateNetworks()
|
||||||
|
{
|
||||||
|
var config = CoreConfigTestFactory.CreateConfigWithTun(ECoreType.Xray, true);
|
||||||
|
config.TunModeItem.RouteExcludeAddress =
|
||||||
|
[
|
||||||
|
"0.0.0.0/1", "128.0.0.1/2", "128.0.0.1/2", "128.0.0.1/32",
|
||||||
|
"::/1", "8000::1/2", "8000::1/128"
|
||||||
|
];
|
||||||
|
var generated = await GenerateConfig(config);
|
||||||
|
|
||||||
|
var routes = generated.inbounds.Single(i => i.protocol == "tun").settings.autoSystemRoutingTable;
|
||||||
|
await routes.Should().BeEquivalentTo(new[] { "192.0.0.0/2", "c000::/2" }, StringComparer.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GenerateClientConfigContent_TunExclusions_ShouldAllowExcludingBothAddressFamilies()
|
||||||
|
{
|
||||||
|
var config = CoreConfigTestFactory.CreateConfigWithTun(ECoreType.Xray, true);
|
||||||
|
config.TunModeItem.RouteExcludeAddress = ["0.0.0.0/0", "::/0"];
|
||||||
|
var generated = await GenerateConfig(config);
|
||||||
|
|
||||||
|
var routes = generated.inbounds.Single(i => i.protocol == "tun").settings.autoSystemRoutingTable;
|
||||||
|
await routes.Should().BeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GenerateClientConfigContent_TunHostExclusions_ShouldPreserveNeighbouringAddresses()
|
||||||
|
{
|
||||||
|
var config = CoreConfigTestFactory.CreateConfigWithTun(ECoreType.Xray, true);
|
||||||
|
config.TunModeItem.RouteExcludeAddress =
|
||||||
|
[
|
||||||
|
"0.0.0.0/32", "255.255.255.255/32",
|
||||||
|
"::/128", "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff/128"
|
||||||
|
];
|
||||||
|
var generated = await GenerateConfig(config);
|
||||||
|
var routes = generated.inbounds.Single(i => i.protocol == "tun").settings.autoSystemRoutingTable
|
||||||
|
.Select(IPNetwork.Parse).ToArray();
|
||||||
|
|
||||||
|
foreach (var address in new[]
|
||||||
|
{
|
||||||
|
"0.0.0.0", "255.255.255.255", "::", "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff"
|
||||||
|
})
|
||||||
|
{
|
||||||
|
await routes.Any(route => route.Contains(IPAddress.Parse(address))).Should().BeFalse();
|
||||||
|
}
|
||||||
|
foreach (var address in new[]
|
||||||
|
{
|
||||||
|
"0.0.0.1", "255.255.255.254", "::1", "ffff:ffff:ffff:ffff:ffff:ffff:ffff:fffe"
|
||||||
|
})
|
||||||
|
{
|
||||||
|
await routes.Any(route => route.Contains(IPAddress.Parse(address))).Should().BeTrue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
[Arguments("198.18.0.0/15", 131071L)]
|
||||||
|
[Arguments("0.0.0.0/0", 4294967295L)]
|
||||||
|
[Arguments("198.18.0.0/31", 1L)]
|
||||||
|
[Arguments("198.18.0.1/32", 65535L)]
|
||||||
|
[Arguments("fd00::/64", long.MaxValue)]
|
||||||
|
[Arguments("fd00::/66", 4611686018427387903L)]
|
||||||
|
[Arguments("fd00::/127", 1L)]
|
||||||
|
[Arguments("fd00::1/128", 65535L)]
|
||||||
|
[Arguments("invalid-network", 65535L)]
|
||||||
|
[Arguments("198.18.0.0/33", 65535L)]
|
||||||
|
[Arguments("fd00::/129", 65535L)]
|
||||||
|
public async Task GenerateClientConfigContent_FakeDns_ShouldDerivePoolSizeOrUseFallback(
|
||||||
|
string range, long expectedPoolSize)
|
||||||
|
{
|
||||||
|
var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
|
||||||
|
config.SimpleDNSItem.FakeIP = true;
|
||||||
|
config.SimpleDNSItem.FakeIPRange = range;
|
||||||
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
|
var node = CoreConfigTestFactory.CreateVmessNode(ECoreType.Xray);
|
||||||
|
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.Xray);
|
||||||
|
context.RoutingItem!.RuleSet = JsonUtils.Serialize(new List<RulesItem>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
RuleType = ERuleType.DNS,
|
||||||
|
OutboundTag = Global.ProxyTag,
|
||||||
|
Domain = ["full:example.com"],
|
||||||
|
}
|
||||||
|
});
|
||||||
|
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
|
||||||
|
await result.Success.Should().BeTrue();
|
||||||
|
var generated = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
|
||||||
|
|
||||||
|
await generated.fakedns.Should().NotBeNull();
|
||||||
|
await generated.fakedns!["ipPool"]!.GetValue<string>().Should().BeEqualTo(range);
|
||||||
|
await generated.fakedns["poolSize"]!.GetValue<long>().Should().BeEqualTo(expectedPoolSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<V2rayConfig> GenerateConfig(Config config)
|
||||||
|
{
|
||||||
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
|
var node = CoreConfigTestFactory.CreateVmessNode(ECoreType.Xray);
|
||||||
|
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.Xray);
|
||||||
|
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
|
await result.Success.Should().BeTrue();
|
||||||
|
var generated = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString());
|
||||||
|
await generated.Should().NotBeNull();
|
||||||
|
return generated!;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,43 @@ namespace ServiceLib.Tests.CoreConfig.V2ray;
|
|||||||
|
|
||||||
public class CoreConfigV2rayServiceTests
|
public class CoreConfigV2rayServiceTests
|
||||||
{
|
{
|
||||||
|
[Test]
|
||||||
|
[Arguments(null)]
|
||||||
|
[Arguments("")]
|
||||||
|
[Arguments("AGH+DQBdAAAgACBAi73MnT3WWXWj4M9MUiTygpjSPUfM/Rj3Iasve4uFQAAkAAEAAQABAAIAAQADAAIAAQACAAIAAgADAAMAAQADAAIAAwADAA5vdXRlci5lY2gudGVzdAAA")]
|
||||||
|
[Arguments("https://dns.example.com/dns-query")]
|
||||||
|
[Arguments("udp://127.0.0.1:5353")]
|
||||||
|
[Arguments("ech.example.com+https://dns.example.com/dns-query")]
|
||||||
|
public async Task GenerateClientConfigContent_Tls_ShouldPreserveEchConfigWithoutLegacyQueryOption(string? echConfigList)
|
||||||
|
{
|
||||||
|
var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
|
||||||
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
|
var node = CoreConfigTestFactory.CreateVmessNode(ECoreType.Xray);
|
||||||
|
node.StreamSecurity = Global.StreamSecurity;
|
||||||
|
node.Sni = "ech.example.com";
|
||||||
|
node.EchConfigList = echConfigList;
|
||||||
|
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.Xray);
|
||||||
|
|
||||||
|
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
|
await result.Success.Should().BeTrue();
|
||||||
|
var generatedConfig = JsonNode.Parse(result.Data!.ToString()!)!;
|
||||||
|
var proxy = generatedConfig["outbounds"]!.AsArray()
|
||||||
|
.First(outbound => outbound!["tag"]!.GetValue<string>() == Global.ProxyTag)!;
|
||||||
|
var tls = proxy["streamSettings"]!["tlsSettings"]!.AsObject();
|
||||||
|
|
||||||
|
await tls["serverName"]!.GetValue<string>().Should().BeEqualTo("ech.example.com");
|
||||||
|
if (string.IsNullOrEmpty(echConfigList))
|
||||||
|
{
|
||||||
|
await tls.ContainsKey("echConfigList").Should().BeFalse();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await tls["echConfigList"]!.GetValue<string>().Should().BeEqualTo(echConfigList);
|
||||||
|
}
|
||||||
|
await tls.ContainsKey("echForceQuery").Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public async Task GenerateClientConfigContent_ShouldGenerateBasicProxyConfig()
|
public async Task GenerateClientConfigContent_ShouldGenerateBasicProxyConfig()
|
||||||
{
|
{
|
||||||
@@ -21,6 +58,61 @@ public class CoreConfigV2rayServiceTests
|
|||||||
await v2rayConfig.inbounds.Should().Contain(i => i.protocol == nameof(EInboundProtocol.mixed));
|
await v2rayConfig.inbounds.Should().Contain(i => i.protocol == nameof(EInboundProtocol.mixed));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GenerateClientConfigContent_RealityFragment_ShouldUsePacketRangeOnlyForReality()
|
||||||
|
{
|
||||||
|
static ProfileItem CreateVlessNode(string indexId, string streamSecurity)
|
||||||
|
{
|
||||||
|
var node = new ProfileItem
|
||||||
|
{
|
||||||
|
IndexId = indexId,
|
||||||
|
ConfigType = EConfigType.VLESS,
|
||||||
|
CoreType = ECoreType.Xray,
|
||||||
|
Remarks = indexId,
|
||||||
|
Address = "example.com",
|
||||||
|
Port = 443,
|
||||||
|
Password = "01234567-89ab-cdef-0123-456789abcdef",
|
||||||
|
Network = nameof(ETransport.raw),
|
||||||
|
StreamSecurity = streamSecurity,
|
||||||
|
PublicKey = "test-public-key",
|
||||||
|
Sni = "example.com",
|
||||||
|
Fingerprint = "chrome",
|
||||||
|
};
|
||||||
|
node.SetProtocolExtra(new ProtocolExtraItem { VlessEncryption = Global.None });
|
||||||
|
node.SetTransportExtra(new TransportExtraItem { RawHeaderType = Global.None });
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
static string GetFragmentPackets(Outbounds4Ray outbound)
|
||||||
|
{
|
||||||
|
var finalmask = JsonUtils.ParseJson(JsonUtils.Serialize(outbound.streamSettings!.finalmask));
|
||||||
|
return finalmask!["tcp"]![0]!["settings"]!["packets"]!.GetValue<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
|
||||||
|
config.CoreBasicItem.EnableFragment = true;
|
||||||
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
|
|
||||||
|
var tlsNode = CreateVlessNode("tls-node", Global.StreamSecurity);
|
||||||
|
var realityNode = CreateVlessNode("reality-node", Global.StreamSecurityReality);
|
||||||
|
var group = CoreConfigTestFactory.CreatePolicyGroupNode(ECoreType.Xray, "group", "group",
|
||||||
|
[tlsNode.IndexId, realityNode.IndexId]);
|
||||||
|
var context = CoreConfigTestFactory.CreateContext(config, group, ECoreType.Xray);
|
||||||
|
context.AllProxiesMap[tlsNode.IndexId] = tlsNode;
|
||||||
|
context.AllProxiesMap[realityNode.IndexId] = realityNode;
|
||||||
|
context.AllProxiesMap[group.IndexId] = group;
|
||||||
|
|
||||||
|
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
|
await result.Success.Should().BeTrue();
|
||||||
|
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
|
||||||
|
var tlsOutbound = cfg.outbounds.Single(o => o.streamSettings?.security == Global.StreamSecurity);
|
||||||
|
var realityOutbound = cfg.outbounds.Single(o => o.streamSettings?.security == Global.StreamSecurityReality);
|
||||||
|
|
||||||
|
await GetFragmentPackets(tlsOutbound).Should().BeEqualTo("tlshello");
|
||||||
|
await GetFragmentPackets(realityOutbound).Should().BeEqualTo("1-3");
|
||||||
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public async Task GenerateClientConfigContent_HttpOutbound_ShouldEmitHeadersInSettings()
|
public async Task GenerateClientConfigContent_HttpOutbound_ShouldEmitHeadersInSettings()
|
||||||
{
|
{
|
||||||
@@ -49,9 +141,8 @@ public class CoreConfigV2rayServiceTests
|
|||||||
await headers["User-Agent"]!.GetValue<string>().Should().BeEqualTo("v2rayN");
|
await headers["User-Agent"]!.GetValue<string>().Should().BeEqualTo("v2rayN");
|
||||||
await headers["Set-Cookie"]!.AsArray()
|
await headers["Set-Cookie"]!.AsArray()
|
||||||
.Select(item => item!.GetValue<string>())
|
.Select(item => item!.GetValue<string>())
|
||||||
.Should().BeEquivalentTo(["a=1", "b=2"]);
|
.Order(StringComparer.Ordinal).SequenceEqual(["a=1", "b=2"])
|
||||||
await outbound.settings.servers.Should().BeNull();
|
.Should().BeTrue();
|
||||||
await outbound.settings.vnext.Should().BeNull();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
@@ -588,6 +679,71 @@ public class CoreConfigV2rayServiceTests
|
|||||||
await tunInbound.settings.gateway.Should().HaveCount(enableIPv6Address ? 2 : 1);
|
await tunInbound.settings.gateway.Should().HaveCount(enableIPv6Address ? 2 : 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GenerateClientConfigContent_Tun_ShouldSkipIPv6RouteWithoutGlobalIPv6()
|
||||||
|
{
|
||||||
|
// A host without a global IPv6 address has no IPv6 traffic that could bypass the tunnel,
|
||||||
|
// while ::/0 would pull IPv6 attempts into a tunnel they cannot leave.
|
||||||
|
var config = CoreConfigTestFactory.CreateConfigWithTun(ECoreType.Xray, enableIPv6Address: false);
|
||||||
|
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.0.0.0/0");
|
||||||
|
var ipv6Routes = tunInbound.settings.autoSystemRoutingTable!.Where(x => x.Contains(':')).ToList();
|
||||||
|
await ipv6Routes.Should().BeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GenerateClientConfigContent_Tun_ShouldRouteIPv6WhenEnabledWithoutGlobalIPv6()
|
||||||
|
{
|
||||||
|
// Hosts behind NAT66 hold only ULA addresses, so EnableIPv6Address must still route ::/0.
|
||||||
|
var config = CoreConfigTestFactory.CreateConfigWithTun(ECoreType.Xray, enableIPv6Address: true);
|
||||||
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
|
|
||||||
|
var node = CoreConfigTestFactory.CreateVmessNode(ECoreType.Xray, "n-main", "main");
|
||||||
|
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.Xray, hasGlobalIPv6Address: false);
|
||||||
|
|
||||||
|
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
|
await result.Success.Should().BeTrue();
|
||||||
|
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
|
||||||
|
var tunInbound = cfg.inbounds.FirstOrDefault(i => i.protocol == "tun");
|
||||||
|
|
||||||
|
await tunInbound.Should().NotBeNull();
|
||||||
|
await tunInbound!.settings.autoSystemRoutingTable.Should().Contain("::/0");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GenerateClientConfigContent_TunRouteExcludeAddress_ShouldSkipIPv6RangesWithoutGlobalIPv6()
|
||||||
|
{
|
||||||
|
var config = CoreConfigTestFactory.CreateConfigWithTunRouteExcludeAddress(ECoreType.Xray);
|
||||||
|
config.TunModeItem.EnableIPv6Address = false;
|
||||||
|
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().NotBeEmpty();
|
||||||
|
var ipv6Routes = tunInbound.settings.autoSystemRoutingTable!.Where(x => x.Contains(':')).ToList();
|
||||||
|
await ipv6Routes.Should().BeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public async Task GenerateClientConfigContent_TunRouteExcludeAddress_ShouldIncludeIPv6Ranges()
|
public async Task GenerateClientConfigContent_TunRouteExcludeAddress_ShouldIncludeIPv6Ranges()
|
||||||
{
|
{
|
||||||
@@ -667,6 +823,136 @@ public class CoreConfigV2rayServiceTests
|
|||||||
var proxyOutbound = cfg!.outbounds.FirstOrDefault(o => o.tag == Global.ProxyTag);
|
var proxyOutbound = cfg!.outbounds.FirstOrDefault(o => o.tag == Global.ProxyTag);
|
||||||
await proxyOutbound.Should().NotBeNull();
|
await proxyOutbound.Should().NotBeNull();
|
||||||
await proxyOutbound!.protocol.Should().BeEqualTo("shadowsocks");
|
await proxyOutbound!.protocol.Should().BeEqualTo("shadowsocks");
|
||||||
await proxyOutbound.settings.servers.Should().NotBeNull();
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GenerateClientConfigContent_XdriveGoogleDrive_ShouldGenerateValidOutbound()
|
||||||
|
{
|
||||||
|
var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
|
||||||
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
|
|
||||||
|
var node = new ProfileItem
|
||||||
|
{
|
||||||
|
IndexId = "node-xdrive-gdrive",
|
||||||
|
ConfigType = EConfigType.VLESS,
|
||||||
|
CoreType = ECoreType.Xray,
|
||||||
|
Remarks = "xdrive-gdrive",
|
||||||
|
Address = "example.com",
|
||||||
|
Port = 443,
|
||||||
|
Password = Guid.NewGuid().ToString(),
|
||||||
|
Network = nameof(ETransport.xdrive),
|
||||||
|
MuxEnabled = false,
|
||||||
|
};
|
||||||
|
node.SetProtocolExtra(new ProtocolExtraItem { VlessEncryption = Global.None });
|
||||||
|
node.SetTransportExtra(new TransportExtraItem
|
||||||
|
{
|
||||||
|
XdriveService = "google-drive",
|
||||||
|
XdriveRemoteFolder = "folder-123",
|
||||||
|
XdriveSecrets = "id,secret,token",
|
||||||
|
});
|
||||||
|
|
||||||
|
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.Xray);
|
||||||
|
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
|
await result.Success.Should().BeTrue();
|
||||||
|
var generatedConfig = JsonNode.Parse(result.Data!.ToString()!)!;
|
||||||
|
var proxy = generatedConfig["outbounds"]!.AsArray()
|
||||||
|
.First(outbound => outbound!["tag"]!.GetValue<string>() == Global.ProxyTag)!;
|
||||||
|
|
||||||
|
var streamSettings = proxy["streamSettings"]!;
|
||||||
|
await streamSettings["network"]!.GetValue<string>().Should().BeEqualTo("xdrive");
|
||||||
|
|
||||||
|
var xdriveSettings = streamSettings["xdriveSettings"]!;
|
||||||
|
await xdriveSettings["service"]!.GetValue<string>().Should().BeEqualTo("google-drive");
|
||||||
|
await xdriveSettings["remoteFolder"]!.GetValue<string>().Should().BeEqualTo("folder-123");
|
||||||
|
|
||||||
|
var secrets = xdriveSettings["secrets"]!.AsArray();
|
||||||
|
await secrets.Count.Should().BeEqualTo(3);
|
||||||
|
await secrets[0]!.GetValue<string>().Should().BeEqualTo("id");
|
||||||
|
await secrets[1]!.GetValue<string>().Should().BeEqualTo("secret");
|
||||||
|
await secrets[2]!.GetValue<string>().Should().BeEqualTo("token");
|
||||||
|
|
||||||
|
await proxy["mux"]!["enabled"]!.GetValue<bool>().Should().BeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GenerateClientConfigContent_XdriveS3_ShouldGenerateValidOutbound()
|
||||||
|
{
|
||||||
|
var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
|
||||||
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
|
|
||||||
|
var node = new ProfileItem
|
||||||
|
{
|
||||||
|
IndexId = "node-xdrive-s3",
|
||||||
|
ConfigType = EConfigType.VLESS,
|
||||||
|
CoreType = ECoreType.Xray,
|
||||||
|
Remarks = "xdrive-s3",
|
||||||
|
Address = "example.com",
|
||||||
|
Port = 443,
|
||||||
|
Password = Guid.NewGuid().ToString(),
|
||||||
|
Network = nameof(ETransport.xdrive),
|
||||||
|
};
|
||||||
|
node.SetProtocolExtra(new ProtocolExtraItem { VlessEncryption = Global.None });
|
||||||
|
node.SetTransportExtra(new TransportExtraItem
|
||||||
|
{
|
||||||
|
XdriveService = "s3",
|
||||||
|
XdriveS3Endpoint = "https://s3.amazonaws.com",
|
||||||
|
XdriveS3Bucket = "mybucket",
|
||||||
|
XdriveS3PathStyle = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.Xray);
|
||||||
|
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
|
await result.Success.Should().BeTrue();
|
||||||
|
var generatedConfig = JsonNode.Parse(result.Data!.ToString()!)!;
|
||||||
|
var proxy = generatedConfig["outbounds"]!.AsArray()
|
||||||
|
.First(outbound => outbound!["tag"]!.GetValue<string>() == Global.ProxyTag)!;
|
||||||
|
|
||||||
|
var s3 = proxy["streamSettings"]!["xdriveSettings"]!["s3"]!;
|
||||||
|
await s3["endpoint"]!.GetValue<string>().Should().BeEqualTo("https://s3.amazonaws.com");
|
||||||
|
await s3["bucket"]!.GetValue<string>().Should().BeEqualTo("mybucket");
|
||||||
|
await s3["pathStyle"]!.GetValue<bool>().Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GenerateClientConfigContent_XdriveExtra_ShouldPopulateParameters()
|
||||||
|
{
|
||||||
|
var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
|
||||||
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
|
|
||||||
|
var node = new ProfileItem
|
||||||
|
{
|
||||||
|
IndexId = "node-xdrive-extra",
|
||||||
|
ConfigType = EConfigType.VLESS,
|
||||||
|
CoreType = ECoreType.Xray,
|
||||||
|
Remarks = "xdrive-extra",
|
||||||
|
Address = "example.com",
|
||||||
|
Port = 443,
|
||||||
|
Password = Guid.NewGuid().ToString(),
|
||||||
|
Network = nameof(ETransport.xdrive),
|
||||||
|
};
|
||||||
|
node.SetProtocolExtra(new ProtocolExtraItem { VlessEncryption = Global.None });
|
||||||
|
node.SetTransportExtra(new TransportExtraItem
|
||||||
|
{
|
||||||
|
XdriveService = "onedrive",
|
||||||
|
XdriveExtra = """{"segmentBytes":1048576,"flushIntervalMs":500,"pollIntervalMs":200,"maxPollIntervalMs":2000,"eagerWindowMs":1000,"concurrency":4}""",
|
||||||
|
});
|
||||||
|
|
||||||
|
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.Xray);
|
||||||
|
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
|
await result.Success.Should().BeTrue();
|
||||||
|
var generatedConfig = JsonNode.Parse(result.Data!.ToString()!)!;
|
||||||
|
var proxy = generatedConfig["outbounds"]!.AsArray()
|
||||||
|
.First(outbound => outbound!["tag"]!.GetValue<string>() == Global.ProxyTag)!;
|
||||||
|
|
||||||
|
var xdriveSettings = proxy["streamSettings"]!["xdriveSettings"]!;
|
||||||
|
await xdriveSettings["segmentBytes"]!.GetValue<int>().Should().BeEqualTo(1048576);
|
||||||
|
await xdriveSettings["flushIntervalMs"]!.GetValue<int>().Should().BeEqualTo(500);
|
||||||
|
await xdriveSettings["pollIntervalMs"]!.GetValue<int>().Should().BeEqualTo(200);
|
||||||
|
await xdriveSettings["maxPollIntervalMs"]!.GetValue<int>().Should().BeEqualTo(2000);
|
||||||
|
await xdriveSettings["eagerWindowMs"]!.GetValue<int>().Should().BeEqualTo(1000);
|
||||||
|
await xdriveSettings["concurrency"]!.GetValue<int>().Should().BeEqualTo(4);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
namespace ServiceLib.Tests.Fmt;
|
||||||
|
|
||||||
|
public class AnytlsFmtTests
|
||||||
|
{
|
||||||
|
// "服务器的地址和可选端口。如果省略端口,则默认为 443。" (the port is optional and defaults to 443)
|
||||||
|
// -- https://github.com/anytls/anytls-go/blob/main/docs/uri_scheme.md
|
||||||
|
// The first two cases are the examples given there.
|
||||||
|
[Test]
|
||||||
|
[Arguments("anytls://letmein@example.com/?sni=real.example.com")]
|
||||||
|
[Arguments("anytls://letmein@example.com/?sni=127.0.0.1&insecure=1")]
|
||||||
|
[Arguments("anytls://letmein@example.com")]
|
||||||
|
[Arguments("anytls://letmein@example.com:/")]
|
||||||
|
public async Task ResolveConfig_WithoutPort_ShouldDefaultTo443(string shareUri)
|
||||||
|
{
|
||||||
|
var resolved = FmtHandler.ResolveConfig(shareUri, out var msg);
|
||||||
|
|
||||||
|
await resolved.Should().NotBeNull().Because($"uri: {shareUri}, msg: {msg}");
|
||||||
|
await resolved!.ConfigType.Should().BeEqualTo(EConfigType.Anytls);
|
||||||
|
await resolved.Address.Should().BeEqualTo("example.com");
|
||||||
|
await resolved.Port.Should().BeEqualTo(443);
|
||||||
|
await resolved.Password.Should().BeEqualTo("letmein");
|
||||||
|
await resolved.IsValid().Should().BeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task ResolveConfig_WithExplicitPort_ShouldKeepIt()
|
||||||
|
{
|
||||||
|
// The third example from the URI scheme document.
|
||||||
|
const string shareUri = "anytls://0fdf77d7-d4ba-455e-9ed9-a98dd6d5489a@[2409:8a71:6a00:1953::615]:8964/?insecure=1";
|
||||||
|
|
||||||
|
var resolved = FmtHandler.ResolveConfig(shareUri, out var msg);
|
||||||
|
|
||||||
|
await resolved.Should().NotBeNull().Because($"uri: {shareUri}, msg: {msg}");
|
||||||
|
await resolved!.Address.Should().BeEqualTo("2409:8a71:6a00:1953::615");
|
||||||
|
await resolved.Port.Should().BeEqualTo(8964);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task ResolveConfig_WithExplicitZeroPort_ShouldNotApplyTheDefault()
|
||||||
|
{
|
||||||
|
var resolved = FmtHandler.ResolveConfig("anytls://letmein@example.com:0/", out _);
|
||||||
|
|
||||||
|
await resolved.Should().NotBeNull();
|
||||||
|
await resolved!.Port.Should().BeEqualTo(0);
|
||||||
|
await resolved.IsValid().Should().BeFalse();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
namespace ServiceLib.Tests.Fmt;
|
||||||
|
|
||||||
|
public class NaiveFmtTests
|
||||||
|
{
|
||||||
|
// "port can be omitted if it's 443" -- https://gist.github.com/DuckSoft/ca03913b0a26fc77a1da4d01cc6ab2f1
|
||||||
|
// The first two cases are examples given there; none of its examples carries a port.
|
||||||
|
[Test]
|
||||||
|
[Arguments("naive+https://what:happened@test.someone.cf?padding=false#Naive!", false)]
|
||||||
|
[Arguments("naive+quic://manhole:114514@quic.test.me", true)]
|
||||||
|
[Arguments("naive+https://what:happened@test.someone.cf/", false)]
|
||||||
|
[Arguments("naive+https://what:happened@test.someone.cf:/", false)]
|
||||||
|
public async Task ResolveConfig_WithoutPort_ShouldDefaultTo443(string shareUri, bool quic)
|
||||||
|
{
|
||||||
|
var resolved = FmtHandler.ResolveConfig(shareUri, out var msg);
|
||||||
|
|
||||||
|
await resolved.Should().NotBeNull().Because($"uri: {shareUri}, msg: {msg}");
|
||||||
|
await resolved!.ConfigType.Should().BeEqualTo(EConfigType.Naive);
|
||||||
|
await resolved.Port.Should().BeEqualTo(443);
|
||||||
|
await (resolved.GetProtocolExtra().NaiveQuic == true).Should().BeEqualTo(quic);
|
||||||
|
await resolved.IsValid().Should().BeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task ResolveConfig_WithExplicitPort_ShouldKeepIt()
|
||||||
|
{
|
||||||
|
var resolved = FmtHandler.ResolveConfig("naive+https://what:happened@test.someone.cf:8443", out _);
|
||||||
|
|
||||||
|
await resolved.Should().NotBeNull();
|
||||||
|
await resolved!.Address.Should().BeEqualTo("test.someone.cf");
|
||||||
|
await resolved.Port.Should().BeEqualTo(8443);
|
||||||
|
await resolved.Username.Should().BeEqualTo("what");
|
||||||
|
await resolved.Password.Should().BeEqualTo("happened");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task ResolveConfig_WithExplicitZeroPort_ShouldNotApplyTheDefault()
|
||||||
|
{
|
||||||
|
var resolved = FmtHandler.ResolveConfig("naive+https://what:happened@test.someone.cf:0", out _);
|
||||||
|
|
||||||
|
await resolved.Should().NotBeNull();
|
||||||
|
await resolved!.Port.Should().BeEqualTo(0);
|
||||||
|
await resolved.IsValid().Should().BeFalse();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
namespace ServiceLib.Tests.Fmt;
|
||||||
|
|
||||||
|
public class NaiveFmtUserInfoTests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
[Arguments("pa:ss")]
|
||||||
|
[Arguments("a:b:c")]
|
||||||
|
[Arguments(":leading")]
|
||||||
|
[Arguments("trailing:")]
|
||||||
|
public async Task GetShareUriAndResolveConfig_PasswordOnlyWithColon_ShouldKeepPassword(string password)
|
||||||
|
{
|
||||||
|
var source = CreateNaiveProfile(string.Empty, password);
|
||||||
|
|
||||||
|
var uri = FmtHandler.GetShareUri(source);
|
||||||
|
await uri.Should().NotBeNull();
|
||||||
|
|
||||||
|
var resolved = FmtHandler.ResolveConfig(uri!, out var msg);
|
||||||
|
|
||||||
|
await resolved.Should().NotBeNull().Because($"uri: {uri}, msg: {msg}");
|
||||||
|
await resolved!.Username.Should().BeEqualTo(string.Empty);
|
||||||
|
await resolved.Password.Should().BeEqualTo(password);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GetShareUriAndResolveConfig_UsernameAndPasswordWithColons_ShouldKeepBoth()
|
||||||
|
{
|
||||||
|
var source = CreateNaiveProfile("us:er", "pa:ss");
|
||||||
|
|
||||||
|
var uri = FmtHandler.GetShareUri(source);
|
||||||
|
await uri.Should().NotBeNull();
|
||||||
|
|
||||||
|
var resolved = FmtHandler.ResolveConfig(uri!, out var msg);
|
||||||
|
|
||||||
|
await resolved.Should().NotBeNull().Because($"uri: {uri}, msg: {msg}");
|
||||||
|
await resolved!.Username.Should().BeEqualTo("us:er");
|
||||||
|
await resolved.Password.Should().BeEqualTo("pa:ss");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
[Arguments("naive+https://what:happened@test.someone.cf:443", "what", "happened")]
|
||||||
|
[Arguments("naive+https://what:hap:pened@test.someone.cf:443", "what", "hap:pened")]
|
||||||
|
[Arguments("naive+https://wh%40t:hap%3Apened@test.someone.cf:443", "wh@t", "hap:pened")]
|
||||||
|
[Arguments("naive+https://pa%3Ass@test.someone.cf:443", "", "pa:ss")]
|
||||||
|
[Arguments("naive+quic://secret@test.someone.cf:443", "", "secret")]
|
||||||
|
public async Task ResolveConfig_ShouldSplitUserInfoBeforeDecoding(string shareUri, string username, string password)
|
||||||
|
{
|
||||||
|
var resolved = FmtHandler.ResolveConfig(shareUri, out var msg);
|
||||||
|
|
||||||
|
await resolved.Should().NotBeNull().Because($"uri: {shareUri}, msg: {msg}");
|
||||||
|
await resolved!.Username.Should().BeEqualTo(username);
|
||||||
|
await resolved.Password.Should().BeEqualTo(password);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ProfileItem CreateNaiveProfile(string username, string password)
|
||||||
|
{
|
||||||
|
var item = new ProfileItem
|
||||||
|
{
|
||||||
|
ConfigType = EConfigType.Naive,
|
||||||
|
Remarks = "naive demo",
|
||||||
|
Address = "naive.example",
|
||||||
|
Port = 443,
|
||||||
|
Username = username,
|
||||||
|
Password = password,
|
||||||
|
Network = nameof(ETransport.raw),
|
||||||
|
StreamSecurity = Global.None,
|
||||||
|
};
|
||||||
|
item.SetTransportExtra(new TransportExtraItem { RawHeaderType = Global.None, });
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
namespace ServiceLib.Tests.Fmt;
|
||||||
|
|
||||||
|
public class ShadowsocksFmtTests
|
||||||
|
{
|
||||||
|
// SIP002 user info that is not base64 is "method:password", percent-encoded once.
|
||||||
|
[Test]
|
||||||
|
[Arguments("ss://2022-blake3-aes-128-gcm:p%2541ss@1.2.3.4:8388#demo", "p%41ss")]
|
||||||
|
[Arguments("ss://2022-blake3-aes-128-gcm:p%25ss@1.2.3.4:8388#demo", "p%ss")]
|
||||||
|
[Arguments("ss://2022-blake3-aes-128-gcm:a%3Ab%2Fc%2B%3D@1.2.3.4:8388#demo", "a:b/c+=")]
|
||||||
|
public async Task ResolveConfig_PlainUserInfo_ShouldDecodePasswordOnce(string shareUri, string password)
|
||||||
|
{
|
||||||
|
var resolved = FmtHandler.ResolveConfig(shareUri, out var msg);
|
||||||
|
|
||||||
|
await resolved.Should().NotBeNull().Because($"uri: {shareUri}, msg: {msg}");
|
||||||
|
await resolved!.ConfigType.Should().BeEqualTo(EConfigType.Shadowsocks);
|
||||||
|
await resolved.GetProtocolExtra().SsMethod.Should().BeEqualTo("2022-blake3-aes-128-gcm");
|
||||||
|
await resolved.Password.Should().BeEqualTo(password);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
namespace ServiceLib.Tests.Fmt;
|
||||||
|
|
||||||
|
public class SocksFmtTests
|
||||||
|
{
|
||||||
|
// The legacy form base64-encodes the whole "user:pass@host:port".
|
||||||
|
[Test]
|
||||||
|
[Arguments("socks://YWRtaW46YWRtaW5AMS4yLjMuNDoxMDgw#demo", "admin", "admin", "1.2.3.4", 1080)]
|
||||||
|
[Arguments("socks://dXNlcjpwYXNzQDEuMi4zLjQ6MTA4MA==#demo", "user", "pass", "1.2.3.4", 1080)]
|
||||||
|
[Arguments("socks://dXNlcjpxd2VydHk/QDEuMi4zLjQ6MTA4MA==#demo", "user", "qwerty?", "1.2.3.4", 1080)]
|
||||||
|
[Arguments("socks://dXNlcjpxd2VydHk_QDEuMi4zLjQ6MTA4MA#demo", "user", "qwerty?", "1.2.3.4", 1080)]
|
||||||
|
public async Task ResolveConfig_LegacyBase64_ShouldDecodeServerAndCredentials(
|
||||||
|
string shareUri, string username, string password, string address, int port)
|
||||||
|
{
|
||||||
|
var resolved = FmtHandler.ResolveConfig(shareUri, out var msg);
|
||||||
|
|
||||||
|
await resolved.Should().NotBeNull().Because($"uri: {shareUri}, msg: {msg}");
|
||||||
|
await resolved!.ConfigType.Should().BeEqualTo(EConfigType.SOCKS);
|
||||||
|
await resolved.Address.Should().BeEqualTo(address);
|
||||||
|
await resolved.Port.Should().BeEqualTo(port);
|
||||||
|
await resolved.Username.Should().BeEqualTo(username);
|
||||||
|
await resolved.Password.Should().BeEqualTo(password);
|
||||||
|
await resolved.Remarks.Should().BeEqualTo("demo");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
[Arguments("socks://YWRtaW46YWRtaW4@1.2.3.4:1080#demo", "admin", "admin")]
|
||||||
|
[Arguments("socks://admin:admin@1.2.3.4:1080#demo", "admin", "admin")]
|
||||||
|
[Arguments("socks://1.2.3.4:1080#demo", "", "")]
|
||||||
|
public async Task ResolveConfig_HostAndPort_ShouldKeepParsingIt(string shareUri, string username, string password)
|
||||||
|
{
|
||||||
|
var resolved = FmtHandler.ResolveConfig(shareUri, out var msg);
|
||||||
|
|
||||||
|
await resolved.Should().NotBeNull().Because($"uri: {shareUri}, msg: {msg}");
|
||||||
|
await resolved!.Address.Should().BeEqualTo("1.2.3.4");
|
||||||
|
await resolved.Port.Should().BeEqualTo(1080);
|
||||||
|
await resolved.Username.Should().BeEqualTo(username);
|
||||||
|
await resolved.Password.Should().BeEqualTo(password);
|
||||||
|
await resolved.Remarks.Should().BeEqualTo("demo");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
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 oldProfile = CoreConfigTestFactory.CreateVmessNode(ECoreType.Xray, $"profile-{Guid.NewGuid():N}", "old");
|
||||||
|
oldProfile.Subid = subId;
|
||||||
|
oldProfile.IsSub = true;
|
||||||
|
await SQLiteHelper.Instance.InsertAsync(oldProfile);
|
||||||
|
|
||||||
|
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);
|
||||||
|
await profiles.Any(profile => profile.IndexId == oldProfile.IndexId).Should().BeFalse();
|
||||||
|
foreach (var profile in profiles)
|
||||||
|
{
|
||||||
|
await profile.ConfigType.Should().BeEqualTo(EConfigType.Custom);
|
||||||
|
await profile.PreSocksPort.Should().BeEqualTo(10808);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task AddBatchServers_UnsuccessfulSubscriptionUpdate_PreservesExistingProfiles()
|
||||||
|
{
|
||||||
|
var config = CoreConfigTestFactory.CreateConfig();
|
||||||
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
|
SQLiteHelper.Instance.CreateTable<SubItem>();
|
||||||
|
SQLiteHelper.Instance.CreateTable<ProfileItem>();
|
||||||
|
|
||||||
|
var subId = $"sub-failed-{Guid.NewGuid():N}";
|
||||||
|
await SQLiteHelper.Instance.ReplaceAsync(new SubItem
|
||||||
|
{
|
||||||
|
Id = subId,
|
||||||
|
Remarks = "sub",
|
||||||
|
Url = "http://127.0.0.1/sub",
|
||||||
|
});
|
||||||
|
|
||||||
|
var existing = CoreConfigTestFactory.CreateVmessNode(ECoreType.Xray, $"profile-{Guid.NewGuid():N}", "existing");
|
||||||
|
existing.Subid = subId;
|
||||||
|
existing.IsSub = true;
|
||||||
|
await SQLiteHelper.Instance.InsertAsync(existing);
|
||||||
|
|
||||||
|
var invalidSubscription = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("not a valid profile"));
|
||||||
|
var count = await ConfigHandler.AddBatchServers(config, invalidSubscription, subId, true);
|
||||||
|
var profiles = await AppManager.Instance.ProfileItems(subId) ?? [];
|
||||||
|
|
||||||
|
await (count < 1).Should().BeTrue();
|
||||||
|
await profiles.Should().HaveCount(1);
|
||||||
|
await profiles[0].IndexId.Should().BeEqualTo(existing.IndexId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
namespace ServiceLib.Tests.Helper;
|
||||||
|
|
||||||
|
public class HttpRequestHeadersHelperTests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
public async Task TryParse_ShouldAcceptEmptySettingsForExistingSubscriptions()
|
||||||
|
{
|
||||||
|
foreach (var json in new string?[] { null, "", " \r\n ", "{}" })
|
||||||
|
{
|
||||||
|
await HttpRequestHeadersHelper.TryParse(json, out var headers).Should().BeTrue();
|
||||||
|
await headers.Count.Should().BeEqualTo(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task TryParse_ShouldPreserveValuesAndUseCaseInsensitiveNames()
|
||||||
|
{
|
||||||
|
const string json = """
|
||||||
|
{
|
||||||
|
"X-hwid": "my_test_device",
|
||||||
|
"Authorization": "Bearer test:token",
|
||||||
|
"accept": "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Empty": ""
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
await HttpRequestHeadersHelper.TryParse(json, out var headers).Should().BeTrue();
|
||||||
|
await headers["x-HWID"].Should().BeEqualTo("my_test_device");
|
||||||
|
await headers["AUTHORIZATION"].Should().BeEqualTo("Bearer test:token");
|
||||||
|
await headers["Accept"].Should().BeEqualTo("application/json");
|
||||||
|
await headers["Content-Type"].Should().BeEqualTo("application/json");
|
||||||
|
await headers["X-Empty"].Should().BeEqualTo("");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
[Arguments("not-json")]
|
||||||
|
[Arguments("null")]
|
||||||
|
[Arguments("[]")]
|
||||||
|
[Arguments("{\"X-Test\": 1}")]
|
||||||
|
[Arguments("{\"X-Test\": null}")]
|
||||||
|
[Arguments("{\"X-Test\": [\"one\", \"two\"]}")]
|
||||||
|
[Arguments("{\"X-Test\": \"one\", \"X-Test\": \"two\"}")]
|
||||||
|
[Arguments("{\"Accept\": \"one\", \"accept\": \"two\"}")]
|
||||||
|
[Arguments("{\"Bad Header\": \"value\"}")]
|
||||||
|
[Arguments("{\"Bad:Header\": \"value\"}")]
|
||||||
|
[Arguments("{\"\": \"value\"}")]
|
||||||
|
[Arguments("{\"X-Test\": \"one\\r\\nInjected: two\"}")]
|
||||||
|
[Arguments("{\"X-Test\": \"one\\nInjected: two\"}")]
|
||||||
|
[Arguments("{\"X-Test\": \"one\\u0000two\"}")]
|
||||||
|
public async Task TryParse_ShouldRejectInvalidHeadersWithoutReturningPartialSettings(string json)
|
||||||
|
{
|
||||||
|
await HttpRequestHeadersHelper.TryParse(json, out var headers).Should().BeFalse();
|
||||||
|
await headers.Count.Should().BeEqualTo(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task RequestHeaders_ShouldSurviveDatabaseMigrationAndEditing()
|
||||||
|
{
|
||||||
|
using var database = new SQLiteConnection(":memory:", false);
|
||||||
|
database.Execute("CREATE TABLE SubItem (Id TEXT PRIMARY KEY, Remarks TEXT, Url TEXT)");
|
||||||
|
database.Execute("INSERT INTO SubItem (Id, Remarks, Url) VALUES (?, ?, ?)", "existing", "Existing", "https://example.com/sub");
|
||||||
|
database.CreateTable<SubItem>();
|
||||||
|
|
||||||
|
var item = database.Find<SubItem>("existing");
|
||||||
|
await HttpRequestHeadersHelper.TryParse(item.RequestHeaders, out var oldHeaders).Should().BeTrue();
|
||||||
|
await oldHeaders.Count.Should().BeEqualTo(0);
|
||||||
|
|
||||||
|
item.RequestHeaders = "{\"X-hwid\":\"my_device\"}";
|
||||||
|
database.Update(item, typeof(SubItem));
|
||||||
|
await database.Find<SubItem>(item.Id).RequestHeaders.Should().BeEqualTo(item.RequestHeaders);
|
||||||
|
|
||||||
|
item.RequestHeaders = "";
|
||||||
|
database.Update(item, typeof(SubItem));
|
||||||
|
await database.Find<SubItem>(item.Id).RequestHeaders.Should().BeEqualTo("");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
namespace ServiceLib.Tests.Helper;
|
||||||
|
|
||||||
|
public class RegexGuardTests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
public async Task IsRegexMatch_NormalPattern_ShouldMatch()
|
||||||
|
{
|
||||||
|
await Utils.IsRegexMatch("HK-node-01", "HK|香港").Should().BeTrue();
|
||||||
|
await Utils.IsRegexMatch("JP-node-01", "HK|香港").Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task IsRegexMatch_EmptyPattern_ShouldPassThrough()
|
||||||
|
{
|
||||||
|
await Utils.IsRegexMatch("anything", "").Should().BeTrue();
|
||||||
|
await Utils.IsRegexMatch("anything", null).Should().BeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task IsRegexMatch_InvalidPattern_ShouldFailOpen()
|
||||||
|
{
|
||||||
|
await Utils.IsRegexMatch("node-01", "([unclosed").Should().BeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task IsRegexMatch_EvilPattern_ShouldTimeoutAndFailOpen()
|
||||||
|
{
|
||||||
|
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||||
|
var result = Utils.IsRegexMatch(new string('a', 30) + "!", "(a+)+$");
|
||||||
|
sw.Stop();
|
||||||
|
|
||||||
|
await result.Should().BeTrue();
|
||||||
|
await (sw.Elapsed < TimeSpan.FromSeconds(30)).Should().BeTrue().Because(
|
||||||
|
$"evil pattern must be cut off by timeout, took {sw.Elapsed}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
namespace ServiceLib.Tests.Helper;
|
||||||
|
|
||||||
|
[NotInParallel]
|
||||||
|
public class SQLiteHelperTests
|
||||||
|
{
|
||||||
|
private static readonly Lazy<SQLiteHelper> Database = new(() =>
|
||||||
|
{
|
||||||
|
var database = SQLiteHelper.Instance;
|
||||||
|
database.CreateTable<HelperRow>();
|
||||||
|
database.CreateTable<ReplacementRow>();
|
||||||
|
database.CreateTable<DeleteAllRow>();
|
||||||
|
database.CreateTable<NoPrimaryKeyRow>();
|
||||||
|
return database;
|
||||||
|
});
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task SingleWrites_ShouldPreserveValuesAndDeleteByPrimaryKey()
|
||||||
|
{
|
||||||
|
var database = Database.Value;
|
||||||
|
var id = Guid.NewGuid().ToString("N");
|
||||||
|
var row = new HelperRow { Id = id, Value = $"{id}-original" };
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await (await database.InsertAsync(row)).Should().BeEqualTo(1);
|
||||||
|
await (await database.ReplaceAsync(new HelperRow { Id = id, Value = $"{id}-replacement" })).Should().BeEqualTo(1);
|
||||||
|
row.Value = $"{id}-updated";
|
||||||
|
await (await database.UpdateAsync(row)).Should().BeEqualTo(1);
|
||||||
|
|
||||||
|
var rows = await database.QueryAsync<HelperRow>($"SELECT * FROM SQLiteHelperTestRows WHERE Id = '{id}'");
|
||||||
|
await rows.Count.Should().BeEqualTo(1);
|
||||||
|
await rows[0].Value.Should().BeEqualTo($"{id}-updated");
|
||||||
|
await (await database.TableAsync<HelperRow>().Where(item => item.Id == id).CountAsync()).Should().BeEqualTo(1);
|
||||||
|
|
||||||
|
row.Value = "not-the-stored-value";
|
||||||
|
await (await database.DeleteAsync(row)).Should().BeEqualTo(1);
|
||||||
|
await (await database.DeleteAsync(row)).Should().BeEqualTo(0);
|
||||||
|
await (await database.TableAsync<HelperRow>().Where(item => item.Id == id).CountAsync()).Should().BeEqualTo(0);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await database.ExecuteAsync($"DELETE FROM SQLiteHelperTestRows WHERE Id = '{id}'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task BulkWrites_ShouldCommitEveryRowAndReturnAffectedCounts()
|
||||||
|
{
|
||||||
|
var database = Database.Value;
|
||||||
|
var prefix = Guid.NewGuid().ToString("N");
|
||||||
|
var rows = new[]
|
||||||
|
{
|
||||||
|
new HelperRow { Id = $"{prefix}-first", Value = $"{prefix}-original-first" },
|
||||||
|
new HelperRow { Id = $"{prefix}-second", Value = $"{prefix}-original-second" },
|
||||||
|
};
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await (await database.InsertAllAsync(rows)).Should().BeEqualTo(2);
|
||||||
|
rows[0].Value = $"{prefix}-updated-first";
|
||||||
|
rows[1].Value = $"{prefix}-updated-second";
|
||||||
|
await (await database.UpdateAllAsync(rows)).Should().BeEqualTo(2);
|
||||||
|
|
||||||
|
var saved = await database.QueryAsync<HelperRow>($"SELECT * FROM SQLiteHelperTestRows WHERE Id LIKE '{prefix}%' ORDER BY Id");
|
||||||
|
await saved.Count.Should().BeEqualTo(2);
|
||||||
|
await saved[0].Value.Should().BeEqualTo($"{prefix}-updated-first");
|
||||||
|
await saved[1].Value.Should().BeEqualTo($"{prefix}-updated-second");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await database.ExecuteAsync($"DELETE FROM SQLiteHelperTestRows WHERE Id LIKE '{prefix}%'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task InsertAllAsync_ShouldRollBackEarlierRowsWhenALaterInsertFails()
|
||||||
|
{
|
||||||
|
var database = Database.Value;
|
||||||
|
var id = Guid.NewGuid().ToString("N");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
SQLiteException? error = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await database.InsertAllAsync(new[]
|
||||||
|
{
|
||||||
|
new HelperRow { Id = id, Value = $"{id}-first" },
|
||||||
|
new HelperRow { Id = id, Value = $"{id}-duplicate" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (SQLiteException exception)
|
||||||
|
{
|
||||||
|
error = exception;
|
||||||
|
}
|
||||||
|
|
||||||
|
await error.Should().NotBeNull();
|
||||||
|
await (await database.TableAsync<HelperRow>().Where(item => item.Id == id).CountAsync()).Should().BeEqualTo(0);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await database.ExecuteAsync($"DELETE FROM SQLiteHelperTestRows WHERE Id = '{id}'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task UpdateAllAsync_ShouldRollBackEarlierChangesWhenALaterUpdateFails()
|
||||||
|
{
|
||||||
|
var database = Database.Value;
|
||||||
|
var prefix = Guid.NewGuid().ToString("N");
|
||||||
|
var first = new HelperRow { Id = $"{prefix}-first", Value = $"{prefix}-original-first" };
|
||||||
|
var second = new HelperRow { Id = $"{prefix}-second", Value = $"{prefix}-original-second" };
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await database.InsertAllAsync(new[] { first, second });
|
||||||
|
first.Value = $"{prefix}-updated-first";
|
||||||
|
second.Value = first.Value;
|
||||||
|
SQLiteException? error = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await database.UpdateAllAsync(new[] { first, second });
|
||||||
|
}
|
||||||
|
catch (SQLiteException exception)
|
||||||
|
{
|
||||||
|
error = exception;
|
||||||
|
}
|
||||||
|
|
||||||
|
await error.Should().NotBeNull();
|
||||||
|
var saved = await database.QueryAsync<HelperRow>($"SELECT * FROM SQLiteHelperTestRows WHERE Id LIKE '{prefix}%' ORDER BY Id");
|
||||||
|
await saved.Count.Should().BeEqualTo(2);
|
||||||
|
await saved[0].Value.Should().BeEqualTo($"{prefix}-original-first");
|
||||||
|
await saved[1].Value.Should().BeEqualTo($"{prefix}-original-second");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await database.ExecuteAsync($"DELETE FROM SQLiteHelperTestRows WHERE Id LIKE '{prefix}%'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task EmptyBatches_ShouldReturnZero()
|
||||||
|
{
|
||||||
|
var database = Database.Value;
|
||||||
|
await (await database.InsertAllAsync(Array.Empty<HelperRow>())).Should().BeEqualTo(0);
|
||||||
|
await (await database.UpdateAllAsync(Array.Empty<HelperRow>())).Should().BeEqualTo(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task DeleteAsync_ShouldRejectRowsWithoutAPrimaryKey()
|
||||||
|
{
|
||||||
|
var database = Database.Value;
|
||||||
|
var value = Guid.NewGuid().ToString("N");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var row = new NoPrimaryKeyRow { Value = value };
|
||||||
|
await database.InsertAsync(row);
|
||||||
|
NotSupportedException? error = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await database.DeleteAsync(row);
|
||||||
|
}
|
||||||
|
catch (NotSupportedException exception)
|
||||||
|
{
|
||||||
|
error = exception;
|
||||||
|
}
|
||||||
|
|
||||||
|
await error.Should().NotBeNull();
|
||||||
|
await (await database.TableAsync<NoPrimaryKeyRow>().Where(item => item.Value == value).CountAsync()).Should().BeEqualTo(1);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await database.ExecuteAsync($"DELETE FROM SQLiteHelperNoPrimaryKeyTestRows WHERE Value = '{value}'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task DeleteAllAsync_ShouldReturnTheNumberOfDeletedRows()
|
||||||
|
{
|
||||||
|
var database = Database.Value;
|
||||||
|
await database.DeleteAllAsync<DeleteAllRow>();
|
||||||
|
await database.InsertAllAsync(new[] { new DeleteAllRow { Id = "first" }, new DeleteAllRow { Id = "second" } });
|
||||||
|
|
||||||
|
await (await database.DeleteAllAsync<DeleteAllRow>()).Should().BeEqualTo(2);
|
||||||
|
await (await database.TableAsync<DeleteAllRow>().CountAsync()).Should().BeEqualTo(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task ReplaceAllAsync_ShouldInsertAndReplaceRowsAndReturnAffectedCounts()
|
||||||
|
{
|
||||||
|
var database = Database.Value;
|
||||||
|
var prefix = Guid.NewGuid().ToString("N");
|
||||||
|
var existing = new HelperRow { Id = $"{prefix}-existing", Value = $"{prefix}-original" };
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await database.InsertAsync(existing);
|
||||||
|
var replacements = new[]
|
||||||
|
{
|
||||||
|
new HelperRow { Id = existing.Id, Value = $"{prefix}-updated" },
|
||||||
|
new HelperRow { Id = $"{prefix}-new", Value = $"{prefix}-new-value" },
|
||||||
|
};
|
||||||
|
|
||||||
|
await (await database.ReplaceAllAsync(replacements)).Should().BeEqualTo(2);
|
||||||
|
await (await database.ReplaceAllAsync(replacements)).Should().BeEqualTo(2);
|
||||||
|
var saved = await database.QueryAsync<HelperRow>($"SELECT * FROM SQLiteHelperTestRows WHERE Id LIKE '{prefix}%' ORDER BY Id");
|
||||||
|
|
||||||
|
await saved.Count.Should().BeEqualTo(2);
|
||||||
|
await saved[0].Value.Should().BeEqualTo($"{prefix}-updated");
|
||||||
|
await saved[1].Value.Should().BeEqualTo($"{prefix}-new-value");
|
||||||
|
await (await database.ReplaceAllAsync(Array.Empty<HelperRow>())).Should().BeEqualTo(0);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await database.ExecuteAsync($"DELETE FROM SQLiteHelperTestRows WHERE Id LIKE '{prefix}%'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task ReplaceAllAsync_ShouldRollBackReplacementsAndInsertsWhenALaterRowFails()
|
||||||
|
{
|
||||||
|
var database = Database.Value;
|
||||||
|
var prefix = Guid.NewGuid().ToString("N");
|
||||||
|
var existing = new ReplacementRow { Id = $"{prefix}-existing", Value = "original" };
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await database.InsertAsync(existing);
|
||||||
|
SQLiteException? error = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await database.ReplaceAllAsync(new[]
|
||||||
|
{
|
||||||
|
new ReplacementRow { Id = existing.Id, Value = "replacement" },
|
||||||
|
new ReplacementRow { Id = $"{prefix}-new", Value = "inserted" },
|
||||||
|
new ReplacementRow { Id = $"{prefix}-invalid", Value = null! },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (SQLiteException exception)
|
||||||
|
{
|
||||||
|
error = exception;
|
||||||
|
}
|
||||||
|
|
||||||
|
await error.Should().NotBeNull();
|
||||||
|
var saved = await database.QueryAsync<ReplacementRow>($"SELECT * FROM SQLiteHelperReplaceAllTestRows WHERE Id LIKE '{prefix}%'");
|
||||||
|
await saved.Count.Should().BeEqualTo(1);
|
||||||
|
await saved[0].Id.Should().BeEqualTo(existing.Id);
|
||||||
|
await saved[0].Value.Should().BeEqualTo("original");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await database.ExecuteAsync($"DELETE FROM SQLiteHelperReplaceAllTestRows WHERE Id LIKE '{prefix}%'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Table("SQLiteHelperTestRows")]
|
||||||
|
public class HelperRow
|
||||||
|
{
|
||||||
|
[PrimaryKey]
|
||||||
|
public string Id { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Unique]
|
||||||
|
public string Value { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Table("SQLiteHelperDeleteAllTestRows")]
|
||||||
|
public class DeleteAllRow
|
||||||
|
{
|
||||||
|
[PrimaryKey]
|
||||||
|
public string Id { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Table("SQLiteHelperReplaceAllTestRows")]
|
||||||
|
public class ReplacementRow
|
||||||
|
{
|
||||||
|
[PrimaryKey]
|
||||||
|
public string Id { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[NotNull]
|
||||||
|
public string Value { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Table("SQLiteHelperNoPrimaryKeyTestRows")]
|
||||||
|
public class NoPrimaryKeyRow
|
||||||
|
{
|
||||||
|
public string Value { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
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 RunConcurrentUpdates(8, 10, (_, index) => manager.SetTestDelay($"same{index}", index));
|
||||||
|
|
||||||
|
var items = await manager.GetProfileExs();
|
||||||
|
await items.Count.Should().BeEqualTo(10);
|
||||||
|
await items.Select(t => t.IndexId).Distinct().Count().Should().BeEqualTo(10);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task SetTestResults_ConcurrentUpdates_ShouldKeepAllValuesOnOneEntry()
|
||||||
|
{
|
||||||
|
var manager = CreateManager();
|
||||||
|
Action<string>[] updates =
|
||||||
|
[
|
||||||
|
indexId => manager.SetTestDelay(indexId, 123),
|
||||||
|
indexId => manager.SetTestSpeed(indexId, 45.6m),
|
||||||
|
indexId => manager.SetTestMessage(indexId, "result"),
|
||||||
|
indexId => manager.SetTestIpInfo(indexId, "203.0.113.1"),
|
||||||
|
indexId => manager.SetSort(indexId, 7)
|
||||||
|
];
|
||||||
|
|
||||||
|
await RunConcurrentUpdates(updates.Length, 100, (worker, index) => updates[worker]($"profile{index}"));
|
||||||
|
|
||||||
|
var items = await manager.GetProfileExs();
|
||||||
|
await items.Count.Should().BeEqualTo(100);
|
||||||
|
await items.Select(item => item.IndexId).Distinct().Count().Should().BeEqualTo(100);
|
||||||
|
foreach (var item in items)
|
||||||
|
{
|
||||||
|
await item.Delay.Should().BeEqualTo(123);
|
||||||
|
await item.Speed.Should().BeEqualTo(45.6m);
|
||||||
|
await item.Message.Should().BeEqualTo("result");
|
||||||
|
await item.IpInfo.Should().BeEqualTo("203.0.113.1");
|
||||||
|
await item.Sort.Should().BeEqualTo(7);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task RunConcurrentUpdates(int workers, int indexes, Action<int, int> update)
|
||||||
|
{
|
||||||
|
using var barrier = new Barrier(workers);
|
||||||
|
var tasks = Enumerable.Range(0, workers).Select(worker => Task.Factory.StartNew(() =>
|
||||||
|
{
|
||||||
|
for (var index = 0; index < indexes; index++)
|
||||||
|
{
|
||||||
|
if (!barrier.SignalAndWait(TimeSpan.FromSeconds(10)))
|
||||||
|
{
|
||||||
|
throw new TimeoutException("Concurrent update workers did not synchronize.");
|
||||||
|
}
|
||||||
|
update(worker, index);
|
||||||
|
}
|
||||||
|
}, CancellationToken.None, TaskCreationOptions.LongRunning, TaskScheduler.Default)).ToArray();
|
||||||
|
|
||||||
|
await Task.WhenAll(tasks);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,92 @@
|
|||||||
|
namespace ServiceLib.Tests.Manager;
|
||||||
|
|
||||||
|
public class ProfileExManagerPersistenceTests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
public async Task SaveTo_NewProfilesWithRepeatedResults_ShouldPersistEveryProfile()
|
||||||
|
{
|
||||||
|
SQLiteHelper.Instance.CreateTable<ProfileExItem>();
|
||||||
|
var manager = new ProfileExManager();
|
||||||
|
var ids = Enumerable.Range(0, 3).Select(_ => Guid.NewGuid().ToString("N")).ToArray();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var id in ids)
|
||||||
|
{
|
||||||
|
manager.SetTestDelay(id, 42);
|
||||||
|
manager.SetTestSpeed(id, 12.5m);
|
||||||
|
manager.SetTestMessage(id, "completed");
|
||||||
|
manager.SetTestIpInfo(id, "192.0.2.1");
|
||||||
|
manager.SetSort(id, 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
await manager.SaveTo();
|
||||||
|
|
||||||
|
foreach (var id in ids)
|
||||||
|
{
|
||||||
|
var saved = await SQLiteHelper.Instance.TableAsync<ProfileExItem>()
|
||||||
|
.Where(t => t.IndexId == id).ToListAsync();
|
||||||
|
await saved.Count.Should().BeEqualTo(1);
|
||||||
|
await saved[0].Delay.Should().BeEqualTo(42);
|
||||||
|
await saved[0].Speed.Should().BeEqualTo(12.5m);
|
||||||
|
await saved[0].Message.Should().BeEqualTo("completed");
|
||||||
|
await saved[0].IpInfo.Should().BeEqualTo("192.0.2.1");
|
||||||
|
await saved[0].Sort.Should().BeEqualTo(7);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
foreach (var id in ids)
|
||||||
|
{
|
||||||
|
await SQLiteHelper.Instance.DeleteAsync(new ProfileExItem { IndexId = id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task SaveTo_MixedNewAndExistingProfiles_ShouldInsertAndUpdateResults()
|
||||||
|
{
|
||||||
|
SQLiteHelper.Instance.CreateTable<ProfileExItem>();
|
||||||
|
var manager = new ProfileExManager();
|
||||||
|
var existingId = Guid.NewGuid().ToString("N");
|
||||||
|
var newId = Guid.NewGuid().ToString("N");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await SQLiteHelper.Instance.InsertAsync(new ProfileExItem
|
||||||
|
{
|
||||||
|
IndexId = existingId,
|
||||||
|
Delay = 1,
|
||||||
|
Speed = 2,
|
||||||
|
});
|
||||||
|
manager.SetTestDelay(existingId, 100);
|
||||||
|
manager.SetTestSpeed(existingId, 20);
|
||||||
|
manager.SetTestDelay(newId, 200);
|
||||||
|
manager.SetTestIpInfo(newId, "192.0.2.2");
|
||||||
|
|
||||||
|
await manager.SaveTo();
|
||||||
|
|
||||||
|
var existing = await SQLiteHelper.Instance.TableAsync<ProfileExItem>()
|
||||||
|
.Where(t => t.IndexId == existingId).FirstAsync();
|
||||||
|
await existing.Delay.Should().BeEqualTo(100);
|
||||||
|
await existing.Speed.Should().BeEqualTo(20m);
|
||||||
|
var inserted = await SQLiteHelper.Instance.TableAsync<ProfileExItem>()
|
||||||
|
.Where(t => t.IndexId == newId).ToListAsync();
|
||||||
|
await inserted.Count.Should().BeEqualTo(1);
|
||||||
|
await inserted[0].Delay.Should().BeEqualTo(200);
|
||||||
|
await inserted[0].IpInfo.Should().BeEqualTo("192.0.2.2");
|
||||||
|
|
||||||
|
manager.SetTestDelay(newId, 300);
|
||||||
|
await manager.SaveTo();
|
||||||
|
var updated = await SQLiteHelper.Instance.TableAsync<ProfileExItem>()
|
||||||
|
.Where(t => t.IndexId == newId).FirstAsync();
|
||||||
|
await updated.Delay.Should().BeEqualTo(300);
|
||||||
|
await updated.IpInfo.Should().BeEqualTo("192.0.2.2");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await SQLiteHelper.Instance.DeleteAsync(new ProfileExItem { IndexId = existingId });
|
||||||
|
await SQLiteHelper.Instance.DeleteAsync(new ProfileExItem { IndexId = newId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
<OutputType>Exe</OutputType>
|
<OutputType>Exe</OutputType>
|
||||||
<IsPackable>false</IsPackable>
|
<IsPackable>false</IsPackable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<PublishAot>true</PublishAot>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
namespace ServiceLib.Tests.Services;
|
||||||
|
|
||||||
|
public class DownloadServiceHeadersTests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
[Arguments(false, false)]
|
||||||
|
[Arguments(false, true)]
|
||||||
|
[Arguments(true, false)]
|
||||||
|
[Arguments(true, true)]
|
||||||
|
public async Task TryDownloadString_ShouldSendCustomHeadersThroughBothDownloaders(bool useProxy, bool failFirstRequest)
|
||||||
|
{
|
||||||
|
await CertPemManager.Instance.Init(new Config { GuiItem = new GUIItem() });
|
||||||
|
await using var server = new SubscriptionHttpServer(failFirstRequest);
|
||||||
|
const string json = """
|
||||||
|
{
|
||||||
|
"accept": "application/json",
|
||||||
|
"user-agent": "CustomSubscriptionClient/1.0",
|
||||||
|
"authorization": "Bearer test-token",
|
||||||
|
"X-hwid": "test-device",
|
||||||
|
"Cookie": "session=test",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
await HttpRequestHeadersHelper.TryParse(json, out var headers).Should().BeTrue();
|
||||||
|
IWebProxy? proxy = useProxy ? new WebProxy(server.Url) : null;
|
||||||
|
var handler = proxy is null ? HttpClientHelper.Instance.DirectHandler : HttpClientHelper.CreateProxySocketsHttpHandler(proxy, new());
|
||||||
|
var headersHandler = HttpRequestHeadersHelper.CreateHandler(handler, "CustomSubscriptionClient/1.0", "*/*", headers);
|
||||||
|
using var service = new DownloadService
|
||||||
|
{
|
||||||
|
Handler = headersHandler,
|
||||||
|
DisposeHandler = true,
|
||||||
|
};
|
||||||
|
var uri = new UriBuilder(useProxy ? "http://subscription.invalid/sub" : server.Url)
|
||||||
|
{
|
||||||
|
UserName = "user",
|
||||||
|
Password = "password",
|
||||||
|
}.Uri;
|
||||||
|
|
||||||
|
var content = await service.TryDownloadString(uri.AbsoluteUri).WaitAsync(TimeSpan.FromSeconds(20));
|
||||||
|
|
||||||
|
await content.Should().BeEqualTo(SubscriptionHttpServer.Body);
|
||||||
|
await (server.Requests.Count >= (failFirstRequest ? 2 : 1)).Should().BeTrue();
|
||||||
|
foreach (var request in server.Requests)
|
||||||
|
{
|
||||||
|
await request["Accept"].Should().BeEqualTo("application/json");
|
||||||
|
await request["User-Agent"].Should().BeEqualTo("CustomSubscriptionClient/1.0");
|
||||||
|
await request["Authorization"].Should().BeEqualTo("Bearer test-token");
|
||||||
|
await request["X-hwid"].Should().BeEqualTo("test-device");
|
||||||
|
await request["Cookie"].Should().BeEqualTo("session=test");
|
||||||
|
await request["Content-Type"].Should().BeEqualTo("application/json");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
[Arguments(false)]
|
||||||
|
[Arguments(true)]
|
||||||
|
public async Task TryDownloadString_ShouldKeepDefaultAcceptUserAgentAndBasicAuth(bool failFirstRequest)
|
||||||
|
{
|
||||||
|
await CertPemManager.Instance.Init(new Config { GuiItem = new GUIItem() });
|
||||||
|
await using var server = new SubscriptionHttpServer(failFirstRequest);
|
||||||
|
using var handler = HttpRequestHeadersHelper.CreateHandler(HttpClientHelper.CreateSocketsHttpHandler(false, new()), "ExistingClient/1.0", "*/*", null);
|
||||||
|
var service = new DownloadService { Handler = handler };
|
||||||
|
var helper = new DownloaderHelper { Handler = handler };
|
||||||
|
var uri = new UriBuilder(server.Url) { UserName = "user", Password = "password" }.Uri;
|
||||||
|
|
||||||
|
await (await service.TryDownloadString(uri.AbsoluteUri).WaitAsync(TimeSpan.FromSeconds(20))).Should().BeEqualTo(SubscriptionHttpServer.Body);
|
||||||
|
await (await helper.DownloadStringAsync(uri.AbsoluteUri).WaitAsync(TimeSpan.FromSeconds(20))).Should().BeEqualTo(SubscriptionHttpServer.Body);
|
||||||
|
foreach (var request in server.Requests)
|
||||||
|
{
|
||||||
|
await request["Accept"].Should().BeEqualTo("*/*");
|
||||||
|
await request["User-Agent"].Should().BeEqualTo("ExistingClient/1.0");
|
||||||
|
await request["Authorization"].Should().BeEqualTo("Basic dXNlcjpwYXNzd29yZA==");
|
||||||
|
await request.ContainsKey("X-hwid").Should().BeFalse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task TryDownloadString_ShouldNotShareHeadersWithOtherDownloads()
|
||||||
|
{
|
||||||
|
await CertPemManager.Instance.Init(new Config { GuiItem = new GUIItem() });
|
||||||
|
await using var server = new SubscriptionHttpServer();
|
||||||
|
var handler = HttpRequestHeadersHelper.CreateHandler(HttpClientHelper.CreateSocketsHttpHandler(false, new()), "TestClient/1.0", "*/*", new Dictionary<string, string> { ["X-hwid"] = "first-device" });
|
||||||
|
using var subscription = new DownloadService
|
||||||
|
{
|
||||||
|
Handler = handler,
|
||||||
|
DisposeHandler = true,
|
||||||
|
};
|
||||||
|
var ordinaryDownload = new DownloadService();
|
||||||
|
|
||||||
|
await (await subscription.TryDownloadString(server.Url)).Should().BeEqualTo(SubscriptionHttpServer.Body);
|
||||||
|
await (await ordinaryDownload.TryDownloadString(server.Url)).Should().BeEqualTo(SubscriptionHttpServer.Body);
|
||||||
|
|
||||||
|
var requests = server.Requests.ToArray();
|
||||||
|
await requests.Length.Should().BeEqualTo(2);
|
||||||
|
await requests[0]["X-hwid"].Should().BeEqualTo("first-device");
|
||||||
|
await requests[1].ContainsKey("X-hwid").Should().BeFalse();
|
||||||
|
await requests[1].ContainsKey("Accept").Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class SubscriptionHttpServer : IAsyncDisposable
|
||||||
|
{
|
||||||
|
public const string Body = "subscription-test-content";
|
||||||
|
private readonly TcpListener _listener = new(IPAddress.Loopback, 0);
|
||||||
|
private readonly CancellationTokenSource _cancellation = new();
|
||||||
|
private readonly Task _serverTask;
|
||||||
|
private readonly bool _failFirstRequest;
|
||||||
|
|
||||||
|
public string Url { get; }
|
||||||
|
public ConcurrentQueue<Dictionary<string, string>> Requests { get; } = new();
|
||||||
|
|
||||||
|
public SubscriptionHttpServer(bool failFirstRequest = false)
|
||||||
|
{
|
||||||
|
_failFirstRequest = failFirstRequest;
|
||||||
|
_listener.Start();
|
||||||
|
Url = $"http://127.0.0.1:{((IPEndPoint)_listener.LocalEndpoint).Port}/subscription";
|
||||||
|
_serverTask = ServeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ServeAsync()
|
||||||
|
{
|
||||||
|
var cancellationToken = _cancellation.Token;
|
||||||
|
while (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
using var client = await _listener.AcceptTcpClientAsync(cancellationToken);
|
||||||
|
await using var stream = client.GetStream();
|
||||||
|
using var reader = new StreamReader(stream, Encoding.ASCII, leaveOpen: true);
|
||||||
|
var requestLine = await reader.ReadLineAsync(cancellationToken);
|
||||||
|
if (requestLine == null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
while (await reader.ReadLineAsync(cancellationToken) is { Length: > 0 } line)
|
||||||
|
{
|
||||||
|
var separator = line.IndexOf(':');
|
||||||
|
var name = line.Substring(0, separator);
|
||||||
|
var value = line.Substring(separator + 1).Trim();
|
||||||
|
headers[name] = headers.TryGetValue(name, out var previous) ? $"{previous}, {value}" : value;
|
||||||
|
}
|
||||||
|
Requests.Enqueue(headers);
|
||||||
|
|
||||||
|
var status = _failFirstRequest && Requests.Count == 1 ? "503 Service Unavailable" : "200 OK";
|
||||||
|
var body = requestLine.StartsWith("HEAD ") ? "" : Body;
|
||||||
|
var response = $"HTTP/1.1 {status}\r\nContent-Length: {Body.Length}\r\nConnection: close\r\n\r\n{body}";
|
||||||
|
await stream.WriteAsync(Encoding.ASCII.GetBytes(response), cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
await _cancellation.CancelAsync();
|
||||||
|
_listener.Stop();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _serverTask;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_cancellation.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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])!;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
namespace ServiceLib.Tests.Services;
|
||||||
|
|
||||||
|
public class SpeedtestServiceResultTests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
[Arguments(ESpeedActionType.Speedtest, 0)]
|
||||||
|
[Arguments(ESpeedActionType.Mixedtest, 0)]
|
||||||
|
[Arguments(ESpeedActionType.Realping, 12.5)]
|
||||||
|
public async Task StartingATest_ShouldClearTheLastSpeedOfSpeedTests(ESpeedActionType actionType, double expected)
|
||||||
|
{
|
||||||
|
var indexId = Guid.NewGuid().ToString();
|
||||||
|
ProfileExManager.Instance.SetTestSpeed(indexId, 12.5m);
|
||||||
|
|
||||||
|
await SetTestResult([new() { IndexId = indexId }], actionType, ResUI.Speedtesting);
|
||||||
|
|
||||||
|
var profileEx = (await ProfileExManager.Instance.GetProfileExs()).First(t => t.IndexId == indexId);
|
||||||
|
await profileEx.Speed.Should().BeEqualTo((decimal)expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Task SetTestResult(List<ServerTestItem> items, ESpeedActionType actionType, string message)
|
||||||
|
{
|
||||||
|
var service = new SpeedtestService(new Config { SpeedTestItem = new() }, _ => Task.CompletedTask);
|
||||||
|
var method = typeof(SpeedtestService).GetMethod("SetTestResultAsync", BindingFlags.Instance | BindingFlags.NonPublic)!;
|
||||||
|
return (Task)method.Invoke(service, [items, actionType, message])!;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using ReactiveUI.Builder;
|
||||||
|
using ServiceLib.Enums;
|
||||||
|
using ServiceLib.Models.Entities;
|
||||||
|
using ServiceLib.Resx;
|
||||||
|
using ServiceLib.Tests.CoreConfig;
|
||||||
|
using ServiceLib.ViewModels;
|
||||||
|
|
||||||
|
namespace ServiceLib.Tests.ViewModels;
|
||||||
|
|
||||||
|
[NotInParallel]
|
||||||
|
public class AddServerViewModelTests
|
||||||
|
{
|
||||||
|
static AddServerViewModelTests()
|
||||||
|
{
|
||||||
|
RxAppBuilder.CreateReactiveUIBuilder().WithCoreServices().BuildApp();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task XdriveServices_ShouldContainExpectedServices()
|
||||||
|
{
|
||||||
|
await Global.XdriveServices.Count.Should().BeEqualTo(3);
|
||||||
|
await Global.XdriveServices[0].Should().BeEqualTo("google-drive");
|
||||||
|
await Global.XdriveServices[1].Should().BeEqualTo("s3");
|
||||||
|
await Global.XdriveServices[2].Should().BeEqualTo("local");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task AddServerViewModel_ShouldBindXdrivePropertiesFromTransportExtra()
|
||||||
|
{
|
||||||
|
CoreConfigTestFactory.BindAppManagerConfig(CoreConfigTestFactory.CreateConfig());
|
||||||
|
|
||||||
|
var profile = new ProfileItem
|
||||||
|
{
|
||||||
|
IndexId = "test-node",
|
||||||
|
ConfigType = EConfigType.VLESS,
|
||||||
|
Address = "example.com",
|
||||||
|
Port = 443,
|
||||||
|
Network = nameof(ETransport.xdrive),
|
||||||
|
};
|
||||||
|
profile.SetTransportExtra(new TransportExtraItem
|
||||||
|
{
|
||||||
|
XdriveService = "s3",
|
||||||
|
XdriveRemoteFolder = "/remote/path",
|
||||||
|
XdriveSecrets = "sec1,sec2",
|
||||||
|
XdriveS3Endpoint = "s3.example.com",
|
||||||
|
XdriveS3Region = "us-west-2",
|
||||||
|
XdriveS3Bucket = "my-bucket",
|
||||||
|
XdriveS3PathStyle = true,
|
||||||
|
XdriveExtra = "{\"concurrency\":4}",
|
||||||
|
});
|
||||||
|
|
||||||
|
var vm = new AddServerViewModel(profile);
|
||||||
|
|
||||||
|
await vm.XdriveService.Should().BeEqualTo("s3");
|
||||||
|
await vm.XdriveRemoteFolder.Should().BeEqualTo("/remote/path");
|
||||||
|
await vm.XdriveSecrets.Should().BeEqualTo("sec1,sec2");
|
||||||
|
await vm.XdriveS3Endpoint.Should().BeEqualTo("s3.example.com");
|
||||||
|
await vm.XdriveS3Region.Should().BeEqualTo("us-west-2");
|
||||||
|
await vm.XdriveS3Bucket.Should().BeEqualTo("my-bucket");
|
||||||
|
await vm.XdriveS3PathStyle.Should().BeEqualTo(true);
|
||||||
|
await vm.XdriveExtra.Should().BeEqualTo("{\"concurrency\":4}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task AddServerViewModel_TransportFriendlyProperties_ShouldAdaptToXdrive()
|
||||||
|
{
|
||||||
|
CoreConfigTestFactory.BindAppManagerConfig(CoreConfigTestFactory.CreateConfig());
|
||||||
|
|
||||||
|
var profile = new ProfileItem
|
||||||
|
{
|
||||||
|
IndexId = "test-node",
|
||||||
|
ConfigType = EConfigType.VLESS,
|
||||||
|
Address = "example.com",
|
||||||
|
Port = 443,
|
||||||
|
Network = nameof(ETransport.xdrive),
|
||||||
|
};
|
||||||
|
profile.SetTransportExtra(new TransportExtraItem
|
||||||
|
{
|
||||||
|
XdriveService = "s3",
|
||||||
|
XdriveRemoteFolder = "/folder",
|
||||||
|
XdriveS3Endpoint = "s3.custom.endpoint",
|
||||||
|
XdriveExtra = "{\"segmentBytes\":1024}",
|
||||||
|
});
|
||||||
|
|
||||||
|
var vm = new AddServerViewModel(profile);
|
||||||
|
|
||||||
|
// Getters
|
||||||
|
await vm.TransportHeaderType.Should().BeEqualTo("s3");
|
||||||
|
await vm.TransportHost.Should().BeEqualTo("s3.custom.endpoint");
|
||||||
|
await vm.TransportPath.Should().BeEqualTo("/folder");
|
||||||
|
await vm.TransportExtraText.Should().BeEqualTo("{\"segmentBytes\":1024}");
|
||||||
|
|
||||||
|
// Setters
|
||||||
|
vm.TransportHeaderType = "google-drive";
|
||||||
|
await vm.XdriveService.Should().BeEqualTo("google-drive");
|
||||||
|
await vm.TransportHeaderType.Should().BeEqualTo("google-drive");
|
||||||
|
|
||||||
|
vm.TransportHost = "s3.new.endpoint";
|
||||||
|
await vm.XdriveS3Endpoint.Should().BeEqualTo("s3.new.endpoint");
|
||||||
|
await vm.TransportHost.Should().BeEqualTo("s3.new.endpoint");
|
||||||
|
|
||||||
|
vm.TransportPath = "/new/folder";
|
||||||
|
await vm.XdriveRemoteFolder.Should().BeEqualTo("/new/folder");
|
||||||
|
await vm.TransportPath.Should().BeEqualTo("/new/folder");
|
||||||
|
|
||||||
|
vm.TransportExtraText = "{\"flushIntervalMs\":500}";
|
||||||
|
await vm.XdriveExtra.Should().BeEqualTo("{\"flushIntervalMs\":500}");
|
||||||
|
await vm.TransportExtraText.Should().BeEqualTo("{\"flushIntervalMs\":500}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task AddServerViewModel_SaveServerAsync_ShouldPersistXdriveFields()
|
||||||
|
{
|
||||||
|
CoreConfigTestFactory.BindAppManagerConfig(CoreConfigTestFactory.CreateConfig());
|
||||||
|
|
||||||
|
var profile = new ProfileItem
|
||||||
|
{
|
||||||
|
IndexId = "test-node",
|
||||||
|
Remarks = "test remarks",
|
||||||
|
ConfigType = EConfigType.VLESS,
|
||||||
|
Address = "example.com",
|
||||||
|
Port = 443,
|
||||||
|
Password = Guid.NewGuid().ToString(),
|
||||||
|
Network = nameof(ETransport.xdrive),
|
||||||
|
};
|
||||||
|
|
||||||
|
var vm = new AddServerViewModel(profile);
|
||||||
|
vm.XdriveService = "s3";
|
||||||
|
vm.XdriveRemoteFolder = "/backup";
|
||||||
|
vm.XdriveSecrets = "key,secret";
|
||||||
|
vm.XdriveS3Endpoint = "s3.amazonaws.com";
|
||||||
|
vm.XdriveS3Region = "eu-central-1";
|
||||||
|
vm.XdriveS3Bucket = "bucket-1";
|
||||||
|
vm.XdriveS3PathStyle = false;
|
||||||
|
vm.XdriveExtra = "{\"concurrency\":8}";
|
||||||
|
|
||||||
|
var saveMethod = typeof(AddServerViewModel).GetMethod("SaveServerAsync", BindingFlags.Instance | BindingFlags.NonPublic)!;
|
||||||
|
await (Task)saveMethod.Invoke(vm, null)!;
|
||||||
|
|
||||||
|
var extra = vm.SelectedSource.GetTransportExtra();
|
||||||
|
await extra.XdriveService.Should().BeEqualTo("s3");
|
||||||
|
await extra.XdriveRemoteFolder.Should().BeEqualTo("/backup");
|
||||||
|
await extra.XdriveSecrets.Should().BeEqualTo("key,secret");
|
||||||
|
await extra.XdriveS3Endpoint.Should().BeEqualTo("s3.amazonaws.com");
|
||||||
|
await extra.XdriveS3Region.Should().BeEqualTo("eu-central-1");
|
||||||
|
await extra.XdriveS3Bucket.Should().BeEqualTo("bucket-1");
|
||||||
|
await extra.XdriveS3PathStyle.Should().BeEqualTo(false);
|
||||||
|
await extra.XdriveExtra.Should().BeEqualTo("{\"concurrency\":8}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Localization_ShouldProvideXdriveStrings()
|
||||||
|
{
|
||||||
|
await ResUI.XdriveService.IsNotEmpty().Should().BeTrue();
|
||||||
|
await ResUI.XdriveRemoteFolder.IsNotEmpty().Should().BeTrue();
|
||||||
|
await ResUI.XdriveSecrets.IsNotEmpty().Should().BeTrue();
|
||||||
|
await ResUI.XdriveS3Endpoint.IsNotEmpty().Should().BeTrue();
|
||||||
|
await ResUI.XdriveS3Region.IsNotEmpty().Should().BeTrue();
|
||||||
|
await ResUI.XdriveS3Bucket.IsNotEmpty().Should().BeTrue();
|
||||||
|
await ResUI.XdriveS3PathStyle.IsNotEmpty().Should().BeTrue();
|
||||||
|
await ResUI.XdriveExtra.IsNotEmpty().Should().BeTrue();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,16 +2,21 @@ namespace ServiceLib.UdpTest;
|
|||||||
|
|
||||||
public class Socks5UdpChannel(string socks5Host, int socks5TcpPort) : IDisposable
|
public class Socks5UdpChannel(string socks5Host, int socks5TcpPort) : IDisposable
|
||||||
{
|
{
|
||||||
|
private bool _initialized;
|
||||||
|
private IPEndPoint _relayEndPoint;
|
||||||
private TcpClient _tcpClient;
|
private TcpClient _tcpClient;
|
||||||
private UdpClient _udpClient;
|
private UdpClient _udpClient;
|
||||||
private IPEndPoint _relayEndPoint;
|
|
||||||
|
|
||||||
private bool _initialized = false;
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_tcpClient?.Dispose();
|
||||||
|
_udpClient?.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Send UDP data to a remote endpoint (IP address)
|
/// Send UDP data to a remote endpoint (IP address)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task SendAsync(IPEndPoint remote, byte[] data)
|
public async Task SendAsync(IPEndPoint remote, byte[] data, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var addrData = new Socks5AddressData
|
var addrData = new Socks5AddressData
|
||||||
{
|
{
|
||||||
@@ -19,10 +24,10 @@ public class Socks5UdpChannel(string socks5Host, int socks5TcpPort) : IDisposabl
|
|||||||
? Socks5AddressData.AddrTypeIPv4
|
? Socks5AddressData.AddrTypeIPv4
|
||||||
: Socks5AddressData.AddrTypeIPv6,
|
: Socks5AddressData.AddrTypeIPv6,
|
||||||
Host = remote.Address.ToString(),
|
Host = remote.Address.ToString(),
|
||||||
Port = (ushort)remote.Port
|
Port = (ushort)remote.Port,
|
||||||
};
|
};
|
||||||
var packet = BuildSocks5UdpPacket(addrData, data);
|
var packet = BuildSocks5UdpPacket(addrData, data);
|
||||||
await _udpClient.SendAsync(packet, packet.Length, _relayEndPoint);
|
await _udpClient.SendAsync(packet.AsMemory(), _relayEndPoint, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -31,7 +36,8 @@ public class Socks5UdpChannel(string socks5Host, int socks5TcpPort) : IDisposabl
|
|||||||
/// <param name="host">Domain name or IP address</param>
|
/// <param name="host">Domain name or IP address</param>
|
||||||
/// <param name="port">Port number</param>
|
/// <param name="port">Port number</param>
|
||||||
/// <param name="data">Data to send</param>
|
/// <param name="data">Data to send</param>
|
||||||
public async Task SendAsync(string host, ushort port, byte[] data)
|
/// <param name="ct">Cancellation token</param>
|
||||||
|
public async Task SendAsync(string host, ushort port, byte[] data, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var addrData = new Socks5AddressData();
|
var addrData = new Socks5AddressData();
|
||||||
|
|
||||||
@@ -53,7 +59,8 @@ public class Socks5UdpChannel(string socks5Host, int socks5TcpPort) : IDisposabl
|
|||||||
addrData.Port = port;
|
addrData.Port = port;
|
||||||
|
|
||||||
var packet = BuildSocks5UdpPacket(addrData, data);
|
var packet = BuildSocks5UdpPacket(addrData, data);
|
||||||
await _udpClient.SendAsync(packet, packet.Length, _relayEndPoint);
|
// await _udpClient.SendAsync(packet, packet.Length, _relayEndPoint);
|
||||||
|
await _udpClient.SendAsync(packet.AsMemory(), _relayEndPoint, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -69,16 +76,6 @@ public class Socks5UdpChannel(string socks5Host, int socks5TcpPort) : IDisposabl
|
|||||||
return (remote, payload);
|
return (remote, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Represents a remote endpoint that can be either an IP address or a domain name
|
|
||||||
/// </summary>
|
|
||||||
public class Socks5RemoteEndpoint(string host, ushort port, bool isDomain)
|
|
||||||
{
|
|
||||||
public string Host { get; set; } = host;
|
|
||||||
public ushort Port { get; set; } = port;
|
|
||||||
public bool IsDomain { get; set; } = isDomain;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static byte[] BuildSocks5UdpPacket(Socks5AddressData addressData, byte[] data)
|
private static byte[] BuildSocks5UdpPacket(Socks5AddressData addressData, byte[] data)
|
||||||
{
|
{
|
||||||
using var ms = new MemoryStream();
|
using var ms = new MemoryStream();
|
||||||
@@ -104,6 +101,11 @@ public class Socks5UdpChannel(string socks5Host, int socks5TcpPort) : IDisposabl
|
|||||||
throw new ArgumentException("Invalid SOCKS5 UDP packet: too short");
|
throw new ArgumentException("Invalid SOCKS5 UDP packet: too short");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (packet[0] != 0x00 || packet[1] != 0x00)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Invalid SOCKS5 UDP packet: RSV field must be 0");
|
||||||
|
}
|
||||||
|
|
||||||
var offset = 0;
|
var offset = 0;
|
||||||
|
|
||||||
// RSV (2 bytes) - Reserved field, skip
|
// RSV (2 bytes) - Reserved field, skip
|
||||||
@@ -196,83 +198,34 @@ public class Socks5UdpChannel(string socks5Host, int socks5TcpPort) : IDisposabl
|
|||||||
return (remote, data);
|
return (remote, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
/// <summary>
|
||||||
|
/// Represents a remote endpoint that can be either an IP address or a domain name
|
||||||
|
/// </summary>
|
||||||
|
public class Socks5RemoteEndpoint(string host, ushort port, bool isDomain)
|
||||||
{
|
{
|
||||||
_tcpClient.Dispose();
|
public string Host { get; set; } = host;
|
||||||
_udpClient.Dispose();
|
public ushort Port { get; set; } = port;
|
||||||
|
public bool IsDomain { get; set; } = isDomain;
|
||||||
}
|
}
|
||||||
|
|
||||||
#region SOCKS5 Connection Handling
|
|
||||||
|
|
||||||
private const byte Socks5Version = 0x05;
|
|
||||||
private const byte SocksCmdUdpAssociate = 0x03;
|
|
||||||
|
|
||||||
public async Task<bool> EstablishUdpAssociationAsync(CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
if (_initialized)
|
|
||||||
{
|
|
||||||
Dispose();
|
|
||||||
_initialized = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
_udpClient = new UdpClient(new IPEndPoint(IPAddress.Any, 0));
|
|
||||||
_tcpClient = new TcpClient();
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await _tcpClient.ConnectAsync(socks5Host, socks5TcpPort, cancellationToken).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
catch (SocketException)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
var tcpControlStream = _tcpClient.GetStream();
|
|
||||||
|
|
||||||
byte[] handshakeRequest = [Socks5Version, 0x01, 0x00];
|
|
||||||
await tcpControlStream.WriteAsync(handshakeRequest, cancellationToken).ConfigureAwait(false);
|
|
||||||
var handshakeResponse = new byte[2];
|
|
||||||
if (await tcpControlStream.ReadAsync(handshakeResponse, cancellationToken).ConfigureAwait(false) < 2 ||
|
|
||||||
handshakeResponse[0] != Socks5Version || handshakeResponse[1] != 0x00)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
var clientAddrForSocks = new Socks5AddressData
|
|
||||||
{
|
|
||||||
AddressType = Socks5AddressData.AddrTypeIPv4,
|
|
||||||
Host = "0.0.0.0",
|
|
||||||
Port = 0
|
|
||||||
};
|
|
||||||
using var udpAssociateReqMs = new MemoryStream();
|
|
||||||
udpAssociateReqMs.WriteByte(Socks5Version);
|
|
||||||
udpAssociateReqMs.WriteByte(SocksCmdUdpAssociate);
|
|
||||||
udpAssociateReqMs.WriteByte(0x00);
|
|
||||||
udpAssociateReqMs.Write(clientAddrForSocks.ToBytes());
|
|
||||||
await tcpControlStream.WriteAsync(udpAssociateReqMs.ToArray(), cancellationToken).ConfigureAwait(false);
|
|
||||||
|
|
||||||
var verRepRsv = new byte[3];
|
|
||||||
if (await tcpControlStream.ReadAsync(verRepRsv, cancellationToken).ConfigureAwait(false) < 3 ||
|
|
||||||
verRepRsv[0] != Socks5Version || verRepRsv[1] != 0x00)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
var proxyRelaySocksAddr =
|
|
||||||
await Socks5AddressData.ParseAsync(tcpControlStream, cancellationToken).ConfigureAwait(false);
|
|
||||||
if (proxyRelaySocksAddr == null || !IPAddress.TryParse(proxyRelaySocksAddr.Host, out var proxyRelayIp))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
_relayEndPoint = new IPEndPoint(proxyRelayIp, proxyRelaySocksAddr.Port);
|
|
||||||
_initialized = true;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion SOCKS5 Connection Handling
|
|
||||||
|
|
||||||
#region SOCKS5 Address Handling
|
#region SOCKS5 Address Handling
|
||||||
|
|
||||||
|
private static async Task<bool> TryReadExactlyAsync(
|
||||||
|
Stream stream,
|
||||||
|
Memory<byte> buffer,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await stream.ReadExactlyAsync(buffer, cancellationToken).ConfigureAwait(false);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (EndOfStreamException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private class Socks5AddressData
|
private class Socks5AddressData
|
||||||
{
|
{
|
||||||
public const byte AddrTypeIPv4 = 0x01;
|
public const byte AddrTypeIPv4 = 0x01;
|
||||||
@@ -343,7 +296,7 @@ public class Socks5UdpChannel(string socks5Host, int socks5TcpPort) : IDisposabl
|
|||||||
var typeByte = new byte[1];
|
var typeByte = new byte[1];
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (await stream.ReadAsync(typeByte.AsMemory(0, 1), ct).ConfigureAwait(false) < 1)
|
if (!await TryReadExactlyAsync(stream, typeByte, ct).ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -353,7 +306,7 @@ public class Socks5UdpChannel(string socks5Host, int socks5TcpPort) : IDisposabl
|
|||||||
{
|
{
|
||||||
case AddrTypeIPv4:
|
case AddrTypeIPv4:
|
||||||
var ipv4Bytes = new byte[4];
|
var ipv4Bytes = new byte[4];
|
||||||
if (await stream.ReadAsync(ipv4Bytes.AsMemory(0, 4), ct).ConfigureAwait(false) < 4)
|
if (!await TryReadExactlyAsync(stream, ipv4Bytes, ct).ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -363,7 +316,7 @@ public class Socks5UdpChannel(string socks5Host, int socks5TcpPort) : IDisposabl
|
|||||||
|
|
||||||
case AddrTypeDomain:
|
case AddrTypeDomain:
|
||||||
var lenByte = new byte[1];
|
var lenByte = new byte[1];
|
||||||
if (await stream.ReadAsync(lenByte.AsMemory(0, 1), ct).ConfigureAwait(false) < 1)
|
if (!await TryReadExactlyAsync(stream, lenByte, ct).ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -375,8 +328,7 @@ public class Socks5UdpChannel(string socks5Host, int socks5TcpPort) : IDisposabl
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
var domainBytes = new byte[lenByte[0]];
|
var domainBytes = new byte[lenByte[0]];
|
||||||
if (await stream.ReadAsync(domainBytes.AsMemory(0, domainBytes.Length), ct)
|
if (!await TryReadExactlyAsync(stream, domainBytes, ct).ConfigureAwait(false))
|
||||||
.ConfigureAwait(false) < domainBytes.Length)
|
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -388,7 +340,7 @@ public class Socks5UdpChannel(string socks5Host, int socks5TcpPort) : IDisposabl
|
|||||||
|
|
||||||
case AddrTypeIPv6:
|
case AddrTypeIPv6:
|
||||||
var ipv6Bytes = new byte[16];
|
var ipv6Bytes = new byte[16];
|
||||||
if (await stream.ReadAsync(ipv6Bytes.AsMemory(0, 16), ct).ConfigureAwait(false) < 16)
|
if (!await TryReadExactlyAsync(stream, ipv6Bytes, ct).ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -401,7 +353,7 @@ public class Socks5UdpChannel(string socks5Host, int socks5TcpPort) : IDisposabl
|
|||||||
}
|
}
|
||||||
|
|
||||||
var portBytes = new byte[2];
|
var portBytes = new byte[2];
|
||||||
if (await stream.ReadAsync(portBytes.AsMemory(0, 2), ct).ConfigureAwait(false) < 2)
|
if (!await TryReadExactlyAsync(stream, portBytes, ct).ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -417,4 +369,73 @@ public class Socks5UdpChannel(string socks5Host, int socks5TcpPort) : IDisposabl
|
|||||||
}
|
}
|
||||||
|
|
||||||
#endregion SOCKS5 Address Handling
|
#endregion SOCKS5 Address Handling
|
||||||
|
|
||||||
|
#region SOCKS5 Connection Handling
|
||||||
|
|
||||||
|
private const byte Socks5Version = 0x05;
|
||||||
|
private const byte SocksCmdUdpAssociate = 0x03;
|
||||||
|
|
||||||
|
public async Task<bool> EstablishUdpAssociationAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (_initialized)
|
||||||
|
{
|
||||||
|
Dispose();
|
||||||
|
_initialized = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_udpClient = new UdpClient(new IPEndPoint(IPAddress.Any, 0));
|
||||||
|
_tcpClient = new TcpClient();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _tcpClient.ConnectAsync(socks5Host, socks5TcpPort, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (SocketException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var tcpControlStream = _tcpClient.GetStream();
|
||||||
|
|
||||||
|
byte[] handshakeRequest = [Socks5Version, 0x01, 0x00];
|
||||||
|
await tcpControlStream.WriteAsync(handshakeRequest, cancellationToken).ConfigureAwait(false);
|
||||||
|
var handshakeResponse = new byte[2];
|
||||||
|
if (!await TryReadExactlyAsync(tcpControlStream, handshakeResponse, cancellationToken).ConfigureAwait(false) ||
|
||||||
|
handshakeResponse[0] != Socks5Version || handshakeResponse[1] != 0x00)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var clientAddrForSocks = new Socks5AddressData
|
||||||
|
{
|
||||||
|
AddressType = Socks5AddressData.AddrTypeIPv4,
|
||||||
|
Host = "0.0.0.0",
|
||||||
|
Port = 0,
|
||||||
|
};
|
||||||
|
using var udpAssociateReqMs = new MemoryStream();
|
||||||
|
udpAssociateReqMs.WriteByte(Socks5Version);
|
||||||
|
udpAssociateReqMs.WriteByte(SocksCmdUdpAssociate);
|
||||||
|
udpAssociateReqMs.WriteByte(0x00);
|
||||||
|
udpAssociateReqMs.Write(clientAddrForSocks.ToBytes());
|
||||||
|
await tcpControlStream.WriteAsync(udpAssociateReqMs.ToArray(), cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var verRepRsv = new byte[3];
|
||||||
|
if (!await TryReadExactlyAsync(tcpControlStream, verRepRsv, cancellationToken).ConfigureAwait(false) ||
|
||||||
|
verRepRsv[0] != Socks5Version || verRepRsv[1] != 0x00)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var proxyRelaySocksAddr =
|
||||||
|
await Socks5AddressData.ParseAsync(tcpControlStream, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (proxyRelaySocksAddr == null || !IPAddress.TryParse(proxyRelaySocksAddr.Host, out var proxyRelayIp))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_relayEndPoint = new IPEndPoint(proxyRelayIp, proxyRelaySocksAddr.Port);
|
||||||
|
_initialized = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion SOCKS5 Connection Handling
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ public class DnsService : IUdpTest
|
|||||||
// Question: www.google.com, Type A, Class IN
|
// Question: www.google.com, Type A, Class IN
|
||||||
0x03, 0x77, 0x77, 0x77, 0x06, 0x67, 0x6F, 0x6F,
|
0x03, 0x77, 0x77, 0x77, 0x06, 0x67, 0x6F, 0x6F,
|
||||||
0x67, 0x6C, 0x65, 0x03, 0x63, 0x6F, 0x6D, 0x00,
|
0x67, 0x6C, 0x65, 0x03, 0x63, 0x6F, 0x6D, 0x00,
|
||||||
0x00, 0x01, 0x00, 0x01
|
0x00, 0x01, 0x00, 0x01,
|
||||||
];
|
];
|
||||||
|
|
||||||
public byte[] BuildUdpRequestPacket()
|
public byte[] BuildUdpRequestPacket()
|
||||||
|
|||||||
@@ -17,13 +17,13 @@ public class McBeService : IUdpTest
|
|||||||
0xFD, 0xFD, 0xFD, 0xFD, 0x12, 0x34, 0x56, 0x78,
|
0xFD, 0xFD, 0xFD, 0xFD, 0x12, 0x34, 0x56, 0x78,
|
||||||
// Client GUID (random 16 bytes)
|
// Client GUID (random 16 bytes)
|
||||||
0x66, 0x0E, 0xAB, 0xBC, 0x61, 0x0D, 0x1F, 0x4E,
|
0x66, 0x0E, 0xAB, 0xBC, 0x61, 0x0D, 0x1F, 0x4E,
|
||||||
0xA4, 0x40, 0x8C, 0x65, 0xC1, 0xBE, 0xF5, 0x4B
|
0xA4, 0x40, 0x8C, 0x65, 0xC1, 0xBE, 0xF5, 0x4B,
|
||||||
];
|
];
|
||||||
|
|
||||||
private static readonly byte[] McBeMagicBytes =
|
private static readonly byte[] McBeMagicBytes =
|
||||||
[
|
[
|
||||||
0x00, 0xFF, 0xFF, 0x00, 0xFE, 0xFE, 0xFE, 0xFE,
|
0x00, 0xFF, 0xFF, 0x00, 0xFE, 0xFE, 0xFE, 0xFE,
|
||||||
0xFD, 0xFD, 0xFD, 0xFD, 0x12, 0x34, 0x56, 0x78
|
0xFD, 0xFD, 0xFD, 0xFD, 0x12, 0x34, 0x56, 0x78,
|
||||||
];
|
];
|
||||||
|
|
||||||
private static readonly List<string> ValidGameModes =
|
private static readonly List<string> ValidGameModes =
|
||||||
@@ -31,7 +31,7 @@ public class McBeService : IUdpTest
|
|||||||
"Survival",
|
"Survival",
|
||||||
"Creative",
|
"Creative",
|
||||||
"Adventure",
|
"Adventure",
|
||||||
"Spectator"
|
"Spectator",
|
||||||
];
|
];
|
||||||
|
|
||||||
public byte[] BuildUdpRequestPacket()
|
public byte[] BuildUdpRequestPacket()
|
||||||
@@ -61,6 +61,10 @@ public class McBeService : IUdpTest
|
|||||||
return false; // Magic bytes do not match
|
return false; // Magic bytes do not match
|
||||||
}
|
}
|
||||||
var stringLength = (ushort)((mcbeResponseBytes[33] << 8) | mcbeResponseBytes[34]);
|
var stringLength = (ushort)((mcbeResponseBytes[33] << 8) | mcbeResponseBytes[34]);
|
||||||
|
if (mcbeResponseBytes.Length < 35 + stringLength)
|
||||||
|
{
|
||||||
|
return false; // Not enough data for the string
|
||||||
|
}
|
||||||
var stringData = Encoding.UTF8.GetString(mcbeResponseBytes.Skip(35).Take(stringLength).ToArray());
|
var stringData = Encoding.UTF8.GetString(mcbeResponseBytes.Skip(35).Take(stringLength).ToArray());
|
||||||
var stringParts = stringData.Split(';');
|
var stringParts = stringData.Split(';');
|
||||||
// check Game Mode str
|
// check Game Mode str
|
||||||
|
|||||||
@@ -31,10 +31,7 @@ public class StunService : IUdpTest
|
|||||||
if (stunResponseBytes.Length >= 2)
|
if (stunResponseBytes.Length >= 2)
|
||||||
{
|
{
|
||||||
var messageType = (stunResponseBytes[0] << 8) | stunResponseBytes[1];
|
var messageType = (stunResponseBytes[0] << 8) | stunResponseBytes[1];
|
||||||
if (messageType is 0x0101 or 0x0111)
|
return messageType is 0x0101 or 0x0111;
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ namespace ServiceLib.UdpTest;
|
|||||||
public class UdpTestService
|
public class UdpTestService
|
||||||
{
|
{
|
||||||
private const string DefaultUdpTestType = "ntp";
|
private const string DefaultUdpTestType = "ntp";
|
||||||
private readonly IUdpTest _udpTest;
|
|
||||||
|
|
||||||
private static readonly IReadOnlyDictionary<string, Func<IUdpTest>> UdpTestFactories =
|
private static readonly IReadOnlyDictionary<string, Func<IUdpTest>> UdpTestFactories =
|
||||||
new Dictionary<string, Func<IUdpTest>>(StringComparer.OrdinalIgnoreCase)
|
new Dictionary<string, Func<IUdpTest>>(StringComparer.OrdinalIgnoreCase)
|
||||||
@@ -16,6 +15,8 @@ public class UdpTestService
|
|||||||
["mcbe"] = () => new McBeService(),
|
["mcbe"] = () => new McBeService(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private readonly IUdpTest _udpTest;
|
||||||
|
|
||||||
private UdpTestService(IUdpTest udpTest)
|
private UdpTestService(IUdpTest udpTest)
|
||||||
{
|
{
|
||||||
_udpTest = udpTest;
|
_udpTest = udpTest;
|
||||||
@@ -88,24 +89,26 @@ public class UdpTestService
|
|||||||
return (targetServerHost, _udpTest.GetDefaultTargetPort());
|
return (targetServerHost, _udpTest.GetDefaultTargetPort());
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<TimeSpan> SendUdpRequestAsync(string targetServerHost, int socks5Port, TimeSpan operationTimeout)
|
public async Task<TimeSpan> SendUdpRequestAsync(string targetServerHost, int socks5Port,
|
||||||
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
using var cts = new CancellationTokenSource(operationTimeout);
|
using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||||
var cancellationToken = cts.Token;
|
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct, timeoutCts.Token);
|
||||||
|
var linkedCt = linkedCts.Token;
|
||||||
var udpRequestPacket = _udpTest.BuildUdpRequestPacket();
|
var udpRequestPacket = _udpTest.BuildUdpRequestPacket();
|
||||||
if (udpRequestPacket == null || udpRequestPacket.Length == 0)
|
if (udpRequestPacket == null || udpRequestPacket.Length == 0)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException("Failed to build UDP request packet.");
|
throw new InvalidOperationException("Failed to build UDP request packet.");
|
||||||
}
|
}
|
||||||
using var channel = new Socks5UdpChannel("127.0.0.1", socks5Port);
|
using var channel = new Socks5UdpChannel("127.0.0.1", socks5Port);
|
||||||
if (!await channel.EstablishUdpAssociationAsync(cancellationToken).ConfigureAwait(false))
|
if (!await channel.EstablishUdpAssociationAsync(linkedCt).ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
throw new Exception("Failed to establish UDP association with SOCKS5 proxy.");
|
throw new Exception("Failed to establish UDP association with SOCKS5 proxy.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var (targetHost, targetPort) = ParseHostAndPort(targetServerHost);
|
var (targetHost, targetPort) = ParseHostAndPort(targetServerHost);
|
||||||
|
|
||||||
byte[] udpReceiveResult = null;
|
byte[]? validUdpReceiveResult = null;
|
||||||
|
|
||||||
// Get minimum round trip time from two attempts
|
// Get minimum round trip time from two attempts
|
||||||
var roundTripTime = TimeSpan.MaxValue;
|
var roundTripTime = TimeSpan.MaxValue;
|
||||||
@@ -116,11 +119,15 @@ public class UdpTestService
|
|||||||
{
|
{
|
||||||
var stopwatch = new Stopwatch();
|
var stopwatch = new Stopwatch();
|
||||||
stopwatch.Start();
|
stopwatch.Start();
|
||||||
await channel.SendAsync(targetHost, targetPort, udpRequestPacket).ConfigureAwait(false);
|
await channel.SendAsync(targetHost, targetPort, udpRequestPacket, linkedCt).ConfigureAwait(false);
|
||||||
var (_, receiveResult) = await channel.ReceiveAsync(cancellationToken).ConfigureAwait(false);
|
var (_, receiveResult) = await channel.ReceiveAsync(linkedCt).ConfigureAwait(false);
|
||||||
stopwatch.Stop();
|
stopwatch.Stop();
|
||||||
|
|
||||||
udpReceiveResult = receiveResult;
|
if (!_udpTest.VerifyAndExtractUdpResponse(receiveResult))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
validUdpReceiveResult = receiveResult;
|
||||||
|
|
||||||
var currentRoundTripTime = stopwatch.Elapsed;
|
var currentRoundTripTime = stopwatch.Elapsed;
|
||||||
if (currentRoundTripTime < roundTripTime)
|
if (currentRoundTripTime < roundTripTime)
|
||||||
@@ -128,27 +135,20 @@ public class UdpTestService
|
|||||||
roundTripTime = currentRoundTripTime;
|
roundTripTime = currentRoundTripTime;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch
|
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||||
{
|
|
||||||
if (attempt == 1 && roundTripTime == TimeSpan.MaxValue)
|
|
||||||
{
|
{
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
}
|
catch (OperationCanceledException)
|
||||||
}
|
|
||||||
|
|
||||||
if ((udpReceiveResult?.Length ?? 0) < 4 + 1 + 4 + 2)
|
|
||||||
{
|
{
|
||||||
throw new Exception("Received NTP response is too short.");
|
// Timeout, continue to next attempt
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (udpReceiveResult != null && _udpTest.VerifyAndExtractUdpResponse(udpReceiveResult))
|
if (validUdpReceiveResult != null)
|
||||||
{
|
{
|
||||||
return roundTripTime;
|
return roundTripTime;
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
|
||||||
throw new Exception("Failed to verify and extract UDP response.");
|
throw new Exception("Failed to verify and extract UDP response.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,7 +2,19 @@ namespace ServiceLib.Base;
|
|||||||
|
|
||||||
public class BulkObservableCollection<T> : ObservableCollection<T>
|
public class BulkObservableCollection<T> : ObservableCollection<T>
|
||||||
{
|
{
|
||||||
private bool _suppressNotification = false;
|
private bool _suppressNotification;
|
||||||
|
|
||||||
|
public BulkObservableCollection()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public BulkObservableCollection(IEnumerable<T> collection) : base(collection)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public BulkObservableCollection(List<T> list) : base(list)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
protected override void OnCollectionChanged(NotifyCollectionChangedEventArgs e)
|
protected override void OnCollectionChanged(NotifyCollectionChangedEventArgs e)
|
||||||
{
|
{
|
||||||
@@ -63,4 +75,27 @@ public class BulkObservableCollection<T> : ObservableCollection<T>
|
|||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void ReplaceRange(IEnumerable<T>? collection)
|
||||||
|
{
|
||||||
|
_suppressNotification = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Items.Clear();
|
||||||
|
if (collection != null)
|
||||||
|
{
|
||||||
|
foreach (var item in collection)
|
||||||
|
{
|
||||||
|
Items.Add(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_suppressNotification = false;
|
||||||
|
OnPropertyChanged(new PropertyChangedEventArgs(nameof(Count)));
|
||||||
|
OnPropertyChanged(new PropertyChangedEventArgs("Item[]"));
|
||||||
|
OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
namespace ServiceLib.Base;
|
namespace ServiceLib.Base;
|
||||||
|
|
||||||
public class MyReactiveObject : ReactiveObject
|
public class MyReactiveObject : ReactiveObject, IActivatableViewModel
|
||||||
{
|
{
|
||||||
protected static Config? _config;
|
protected static Config? _config;
|
||||||
|
|
||||||
|
public ViewModelActivator Activator { get; } = new();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,29 +136,23 @@ public static class Extension
|
|||||||
.Replace("\n", replacement);
|
.Replace("\n", replacement);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async Task<TOutput> HandleSafe<TInput, TOutput>(
|
public static IObservable<TOutput> HandleSafe<TInput, TOutput>(
|
||||||
this Interaction<TInput, TOutput> interaction,
|
this Interaction<TInput, TOutput> interaction, TInput input,
|
||||||
TInput input,
|
|
||||||
TOutput defaultValue = default!,
|
TOutput defaultValue = default!,
|
||||||
[CallerMemberName] string memberName = "",
|
[CallerMemberName] string memberName = "",
|
||||||
[CallerFilePath] string filePath = "",
|
[CallerFilePath] string filePath = "",
|
||||||
[CallerLineNumber] int lineNumber = 0)
|
[CallerLineNumber] int lineNumber = 0)
|
||||||
{
|
{
|
||||||
try
|
return Signal.Defer(() => interaction.WhenHandled(input))
|
||||||
|
.Catch<TOutput, UnhandledInteractionException<TInput, TOutput>>(ex =>
|
||||||
{
|
{
|
||||||
return await interaction.Handle(input);
|
Logging.SaveLog($"Unhandled interaction exception in {memberName} at {filePath}:{lineNumber}", ex);
|
||||||
}
|
return Signal.Return(defaultValue);
|
||||||
catch (UnhandledInteractionException<TInput, TOutput> ex)
|
})
|
||||||
|
.Catch<TOutput, Exception>(ex =>
|
||||||
{
|
{
|
||||||
var title = $"Unhandled interaction exception in {memberName} at {filePath}:{lineNumber}";
|
Logging.SaveLog($"Exception occurred while handling interaction in {memberName} at {filePath}:{lineNumber}, input: {input}", ex);
|
||||||
Logging.SaveLog(title, ex);
|
return Signal.Return(defaultValue);
|
||||||
return defaultValue;
|
});
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
var title = $"Exception occurred while handling interaction in {memberName} at {filePath}:{lineNumber}, input: {input}";
|
|
||||||
Logging.SaveLog(title, ex);
|
|
||||||
return defaultValue;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
namespace ServiceLib.Common;
|
||||||
|
|
||||||
|
public static class FakeIPUtils
|
||||||
|
{
|
||||||
|
public static bool TryParseIPv6Range(string? value, out string range, out long poolSize)
|
||||||
|
{
|
||||||
|
range = string.IsNullOrWhiteSpace(value) ? Global.FakeIPv6Ranges.First() : value.Trim();
|
||||||
|
poolSize = 65535;
|
||||||
|
if (!System.Net.IPNetwork.TryParse(range, out var network)
|
||||||
|
|| network.BaseAddress.AddressFamily != AddressFamily.InterNetworkV6
|
||||||
|
|| network.PrefixLength == 128)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
range = network.ToString();
|
||||||
|
var hostBits = 128 - network.PrefixLength;
|
||||||
|
if (hostBits < 16)
|
||||||
|
{
|
||||||
|
// Xray requires the mapping capacity to be smaller than the address pool.
|
||||||
|
poolSize = (1L << hostBits) - 1;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using System.Text.Json.Serialization.Metadata;
|
||||||
|
|
||||||
namespace ServiceLib.Common;
|
namespace ServiceLib.Common;
|
||||||
|
|
||||||
public class JsonUtils
|
public class JsonUtils
|
||||||
@@ -7,40 +9,45 @@ public class JsonUtils
|
|||||||
private static readonly JsonSerializerOptions _defaultDeserializeOptions = new()
|
private static readonly JsonSerializerOptions _defaultDeserializeOptions = new()
|
||||||
{
|
{
|
||||||
PropertyNameCaseInsensitive = true,
|
PropertyNameCaseInsensitive = true,
|
||||||
ReadCommentHandling = JsonCommentHandling.Skip
|
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||||
|
TypeInfoResolver = AppJsonContext.Default,
|
||||||
};
|
};
|
||||||
|
|
||||||
private static readonly JsonSerializerOptions _defaultSerializeOptions = new()
|
private static readonly JsonSerializerOptions _defaultSerializeOptions = new()
|
||||||
{
|
{
|
||||||
WriteIndented = true,
|
WriteIndented = true,
|
||||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||||
|
TypeInfoResolver = AppJsonContext.Default,
|
||||||
};
|
};
|
||||||
|
|
||||||
private static readonly JsonSerializerOptions _defaultSerializeNoIndentedOptions = new()
|
private static readonly JsonSerializerOptions _defaultSerializeNoIndentedOptions = new()
|
||||||
{
|
{
|
||||||
WriteIndented = false,
|
WriteIndented = false,
|
||||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||||
|
TypeInfoResolver = AppJsonContext.Default,
|
||||||
};
|
};
|
||||||
|
|
||||||
private static readonly JsonSerializerOptions _nullValueSerializeOptions = new()
|
private static readonly JsonSerializerOptions _nullValueSerializeOptions = new()
|
||||||
{
|
{
|
||||||
WriteIndented = true,
|
WriteIndented = true,
|
||||||
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
|
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
|
||||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||||
|
TypeInfoResolver = AppJsonContext.Default,
|
||||||
};
|
};
|
||||||
|
|
||||||
private static readonly JsonSerializerOptions _nullValueSerializeNoIndentedOptions = new()
|
private static readonly JsonSerializerOptions _nullValueSerializeNoIndentedOptions = new()
|
||||||
{
|
{
|
||||||
WriteIndented = false,
|
WriteIndented = false,
|
||||||
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
|
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
|
||||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||||
|
TypeInfoResolver = AppJsonContext.Default,
|
||||||
};
|
};
|
||||||
|
|
||||||
private static readonly JsonDocumentOptions _defaultDocumentOptions = new()
|
private static readonly JsonDocumentOptions _defaultDocumentOptions = new()
|
||||||
{
|
{
|
||||||
CommentHandling = JsonCommentHandling.Skip
|
CommentHandling = JsonCommentHandling.Skip,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -72,7 +79,7 @@ public class JsonUtils
|
|||||||
{
|
{
|
||||||
return default;
|
return default;
|
||||||
}
|
}
|
||||||
return JsonSerializer.Deserialize<T>(strJson, _defaultDeserializeOptions);
|
return JsonSerializer.Deserialize(strJson, (JsonTypeInfo<T>)_defaultDeserializeOptions.GetTypeInfo(typeof(T)));
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
@@ -109,7 +116,7 @@ public class JsonUtils
|
|||||||
/// <param name="indented"></param>
|
/// <param name="indented"></param>
|
||||||
/// <param name="nullValue"></param>
|
/// <param name="nullValue"></param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
public static string Serialize(object? obj, bool indented = true, bool nullValue = false)
|
public static string Serialize<T>(T? obj, bool indented = true, bool nullValue = false)
|
||||||
{
|
{
|
||||||
var result = string.Empty;
|
var result = string.Empty;
|
||||||
try
|
try
|
||||||
@@ -125,7 +132,7 @@ public class JsonUtils
|
|||||||
(false, true) => _defaultSerializeOptions,
|
(false, true) => _defaultSerializeOptions,
|
||||||
_ => _defaultSerializeNoIndentedOptions
|
_ => _defaultSerializeNoIndentedOptions
|
||||||
};
|
};
|
||||||
result = JsonSerializer.Serialize(obj, options);
|
result = JsonSerializer.Serialize(obj, (JsonTypeInfo<T>)options.GetTypeInfo(typeof(T)));
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -140,7 +147,7 @@ public class JsonUtils
|
|||||||
/// <param name="obj"></param>
|
/// <param name="obj"></param>
|
||||||
/// <param name="options"></param>
|
/// <param name="options"></param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
public static string Serialize(object? obj, JsonSerializerOptions? options)
|
public static string SerializeCamelCase<T>(T? obj)
|
||||||
{
|
{
|
||||||
var result = string.Empty;
|
var result = string.Empty;
|
||||||
try
|
try
|
||||||
@@ -149,7 +156,14 @@ public class JsonUtils
|
|||||||
{
|
{
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
result = JsonSerializer.Serialize(obj, options ?? _defaultSerializeOptions);
|
var options = new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
WriteIndented = true,
|
||||||
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
TypeInfoResolver = AppJsonContext.Default,
|
||||||
|
};
|
||||||
|
result = JsonSerializer.Serialize(obj, (JsonTypeInfo<T>)options.GetTypeInfo(typeof(T)));
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -162,9 +176,109 @@ public class JsonUtils
|
|||||||
/// SerializeToNode
|
/// SerializeToNode
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="obj"></param>
|
/// <param name="obj"></param>
|
||||||
|
/// <param name="nullValue"></param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
public static JsonNode? SerializeToNode(object? obj, JsonSerializerOptions? options = null)
|
public static JsonNode? SerializeToNode<T>(T? obj, bool nullValue = false)
|
||||||
{
|
{
|
||||||
return JsonSerializer.SerializeToNode(obj, options);
|
var options = nullValue ? _nullValueSerializeOptions : _defaultSerializeOptions;
|
||||||
|
return JsonSerializer.SerializeToNode(obj, (JsonTypeInfo<T>)options.GetTypeInfo(typeof(T)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static JsonArray? SerializeToArray(IEnumerable<string>? values)
|
||||||
|
{
|
||||||
|
if (values is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
var array = new JsonArray();
|
||||||
|
foreach (var value in values)
|
||||||
|
{
|
||||||
|
var valueNode = JsonSerializer.SerializeToNode(value, (JsonTypeInfo<string>)_defaultSerializeOptions.GetTypeInfo(typeof(string)));
|
||||||
|
array.Add(valueNode);
|
||||||
|
}
|
||||||
|
return array;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[JsonSerializable(typeof(Config))]
|
||||||
|
[JsonSerializable(typeof(RulesItem))]
|
||||||
|
[JsonSerializable(typeof(ProfileItem))]
|
||||||
|
[JsonSerializable(typeof(List<RulesItem>))]
|
||||||
|
[JsonSerializable(typeof(List<ProfileItem>))]
|
||||||
|
[JsonSerializable(typeof(ProfileItemModel))]
|
||||||
|
[JsonSerializable(typeof(List<ProfileItemModel>))]
|
||||||
|
[JsonSerializable(typeof(RoutingTemplate))]
|
||||||
|
[JsonSerializable(typeof(DNSItem))]
|
||||||
|
[JsonSerializable(typeof(SimpleDNSItem))]
|
||||||
|
[JsonSerializable(typeof(IPAPIInfo))]
|
||||||
|
[JsonSerializable(typeof(SsSIP008))]
|
||||||
|
[JsonSerializable(typeof(List<SsServer>))]
|
||||||
|
[JsonSerializable(typeof(SsServer))]
|
||||||
|
[JsonSerializable(typeof(VmessQRCode))]
|
||||||
|
[JsonSerializable(typeof(ClashProxies))]
|
||||||
|
[JsonSerializable(typeof(ClashProviders))]
|
||||||
|
[JsonSerializable(typeof(ClashConnections))]
|
||||||
|
[JsonSerializable(typeof(ProtocolExtraItem))]
|
||||||
|
[JsonSerializable(typeof(TransportExtraItem))]
|
||||||
|
[JsonSerializable(typeof(TrafficItem))]
|
||||||
|
[JsonSerializable(typeof(V2rayMetricsVars))]
|
||||||
|
[JsonSerializable(typeof(V2rayMetricsVarsLink))]
|
||||||
|
[JsonSerializable(typeof(GitHubRelease))]
|
||||||
|
[JsonSerializable(typeof(List<GitHubRelease>))]
|
||||||
|
[JsonSerializable(typeof(ServerStatItem))]
|
||||||
|
[JsonSerializable(typeof(WebDavItem))]
|
||||||
|
[JsonSerializable(typeof(KeyEventItem))]
|
||||||
|
[JsonSerializable(typeof(SubItem))]
|
||||||
|
[JsonSerializable(typeof(SingboxConfig))]
|
||||||
|
[JsonSerializable(typeof(Dns4Sbox))]
|
||||||
|
[JsonSerializable(typeof(Inbound4Sbox))]
|
||||||
|
[JsonSerializable(typeof(List<Inbound4Sbox>))]
|
||||||
|
[JsonSerializable(typeof(Endpoints4Sbox))]
|
||||||
|
[JsonSerializable(typeof(List<Endpoints4Sbox>))]
|
||||||
|
[JsonSerializable(typeof(Outbound4Sbox))]
|
||||||
|
[JsonSerializable(typeof(List<Outbound4Sbox>))]
|
||||||
|
[JsonSerializable(typeof(Rule4Sbox))]
|
||||||
|
[JsonSerializable(typeof(Ruleset4Sbox))]
|
||||||
|
[JsonSerializable(typeof(List<Rule4Sbox>))]
|
||||||
|
[JsonSerializable(typeof(List<Ruleset4Sbox>))]
|
||||||
|
[JsonSerializable(typeof(V2rayConfig))]
|
||||||
|
[JsonSerializable(typeof(FakeDns4Ray))]
|
||||||
|
[JsonSerializable(typeof(Dns4Ray))]
|
||||||
|
[JsonSerializable(typeof(DnsServer4Ray))]
|
||||||
|
[JsonSerializable(typeof(Inbounds4Ray))]
|
||||||
|
[JsonSerializable(typeof(List<Inbounds4Ray>))]
|
||||||
|
[JsonSerializable(typeof(Outbounds4Ray))]
|
||||||
|
[JsonSerializable(typeof(List<Outbounds4Ray>))]
|
||||||
|
[JsonSerializable(typeof(RulesItem4Ray))]
|
||||||
|
[JsonSerializable(typeof(List<RulesItem4Ray>))]
|
||||||
|
[JsonSerializable(typeof(Finalmask4Ray))]
|
||||||
|
[JsonSerializable(typeof(string))]
|
||||||
|
[JsonSerializable(typeof(int))]
|
||||||
|
[JsonSerializable(typeof(long))]
|
||||||
|
[JsonSerializable(typeof(double))]
|
||||||
|
[JsonSerializable(typeof(decimal))]
|
||||||
|
[JsonSerializable(typeof(bool))]
|
||||||
|
[JsonSerializable(typeof(Guid))]
|
||||||
|
[JsonSerializable(typeof(DateTime))]
|
||||||
|
[JsonSerializable(typeof(DateTimeOffset))]
|
||||||
|
[JsonSerializable(typeof(TimeSpan))]
|
||||||
|
[JsonSerializable(typeof(int?))]
|
||||||
|
[JsonSerializable(typeof(long?))]
|
||||||
|
[JsonSerializable(typeof(double?))]
|
||||||
|
[JsonSerializable(typeof(bool?))]
|
||||||
|
[JsonSerializable(typeof(DateTime?))]
|
||||||
|
[JsonSerializable(typeof(Guid?))]
|
||||||
|
[JsonSerializable(typeof(string[]))]
|
||||||
|
[JsonSerializable(typeof(int[]))]
|
||||||
|
[JsonSerializable(typeof(byte[]))]
|
||||||
|
[JsonSerializable(typeof(List<string>))]
|
||||||
|
[JsonSerializable(typeof(List<int>))]
|
||||||
|
[JsonSerializable(typeof(Dictionary<string, string>))]
|
||||||
|
[JsonSerializable(typeof(Dictionary<string, object>))]
|
||||||
|
[JsonSerializable(typeof(object))]
|
||||||
|
[JsonSerializable(typeof(JsonElement))]
|
||||||
|
[JsonSerializable(typeof(JsonDocument))]
|
||||||
|
[JsonSerializable(typeof(JsonNode))]
|
||||||
|
[JsonSerializable(typeof(JsonObject))]
|
||||||
|
[JsonSerializable(typeof(JsonArray))]
|
||||||
|
internal partial class AppJsonContext : JsonSerializerContext;
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
namespace ServiceLib.Common;
|
||||||
|
|
||||||
|
public static class NetworkUtils
|
||||||
|
{
|
||||||
|
private static readonly HashSet<NetworkInterfaceType> _allowedPhysicalInterfaceTypes =
|
||||||
|
[
|
||||||
|
NetworkInterfaceType.Ethernet,
|
||||||
|
NetworkInterfaceType.Wireless80211,
|
||||||
|
NetworkInterfaceType.GigabitEthernet,
|
||||||
|
NetworkInterfaceType.FastEthernetFx,
|
||||||
|
NetworkInterfaceType.FastEthernetT,
|
||||||
|
];
|
||||||
|
|
||||||
|
private static readonly string[] _allowedPhysicalNamePrefixes =
|
||||||
|
[
|
||||||
|
// Linux
|
||||||
|
"eth", "enp", "ens", "eno", "enx", "wlp", "wlan",
|
||||||
|
|
||||||
|
// macOS
|
||||||
|
"en0", "en1", "en2",
|
||||||
|
];
|
||||||
|
|
||||||
|
private static readonly string[] _allowedPhysicalNameKeywords =
|
||||||
|
[
|
||||||
|
// Windows / macOS / Generic
|
||||||
|
"ethernet", "wlan", "wi-fi", "wifi", "airport",
|
||||||
|
|
||||||
|
// Chinese
|
||||||
|
"以太网", "无线", "局域网"
|
||||||
|
];
|
||||||
|
|
||||||
|
private static readonly string[] _virtualNameBlacklistKeywords =
|
||||||
|
[
|
||||||
|
// Generic
|
||||||
|
"virtual", "loopback", "loop back",
|
||||||
|
|
||||||
|
// Common virtual / VPN / tunnel adapters
|
||||||
|
"hyper-v", "vmware", "virtualbox", "vbox", "docker", "veth", "virbr", "cni",
|
||||||
|
"flannel", "cali", "kube", "wintun", "wireguard", "zerotier", "tailscale", "utun",
|
||||||
|
"tap-windows", "tap adapter", "openvpn", "anyconnect", "vpn", "clash", "mihomo", "xray_tun",
|
||||||
|
|
||||||
|
// Windows NDIS filter sub-layers & pseudo-interfaces
|
||||||
|
"wan miniport", "lightweight filter", "filter driver", "packet scheduler", "npcap", "kernel debug",
|
||||||
|
|
||||||
|
// Chinese
|
||||||
|
"虚拟", "回环", "环回"
|
||||||
|
];
|
||||||
|
|
||||||
|
public static bool IsAutoBindInterface(string? bindInterface)
|
||||||
|
{
|
||||||
|
return string.Equals(bindInterface?.TrimEx(), Global.BindInterfaceAuto, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<string> GetBindInterfaceNames()
|
||||||
|
{
|
||||||
|
return GetLocalNetworkInterfaceNames().Prepend(Global.BindInterfaceAuto).AppendEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string? GetRealBindInterfaceName(string? bindInterface)
|
||||||
|
{
|
||||||
|
var trimmed = bindInterface?.TrimEx();
|
||||||
|
if (trimmed.IsNullOrEmpty())
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return IsAutoBindInterface(trimmed) ? GetDefaultPhysicalInterfaceName() : trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves a default physical outbound interface using a heuristic (active IP-enabled physical NICs
|
||||||
|
/// with an IPv4 gateway, preferring Ethernet over Wi-Fi). This does not inspect OS routing metrics;
|
||||||
|
/// in multi-NIC environments with custom routing tables, specify the target interface name manually.
|
||||||
|
/// </summary>
|
||||||
|
public static string? GetDefaultPhysicalInterfaceName()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var upPhysicalNics = NetworkInterface.GetAllNetworkInterfaces()
|
||||||
|
.Where(IsAllowedPhysicalInterface)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (upPhysicalNics.Count == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var gatewayNics = upPhysicalNics.Where(HasIPv4Gateway).ToList();
|
||||||
|
var candidates = gatewayNics.Count > 0 ? gatewayNics : upPhysicalNics;
|
||||||
|
|
||||||
|
var bestNic = candidates.FirstOrDefault(ni =>
|
||||||
|
ni.NetworkInterfaceType is NetworkInterfaceType.Ethernet or NetworkInterfaceType.GigabitEthernet)
|
||||||
|
?? candidates.FirstOrDefault(ni => ni.NetworkInterfaceType == NetworkInterfaceType.Wireless80211)
|
||||||
|
?? candidates.FirstOrDefault();
|
||||||
|
|
||||||
|
return bestNic?.Name;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logging.SaveLog("GetDefaultPhysicalInterfaceName", ex);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<string> GetLocalNetworkInterfaceNames()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return NetworkInterface.GetAllNetworkInterfaces()
|
||||||
|
.Where(IsAllowedPhysicalInterface)
|
||||||
|
.Select(x => x.Name)
|
||||||
|
.Where(x => !string.IsNullOrWhiteSpace(x))
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.OrderBy(x => x)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logging.SaveLog("GetLocalNetworkInterfaceNames", ex);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsAllowedPhysicalInterface(NetworkInterface ni)
|
||||||
|
{
|
||||||
|
if (ni.OperationalStatus != OperationalStatus.Up || !SupportsIP(ni))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var interfaceText = $"{ni.Name} {ni.Description}";
|
||||||
|
if (_virtualNameBlacklistKeywords.Any(keyword => interfaceText.Contains(keyword, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_allowedPhysicalInterfaceTypes.Contains(ni.NetworkInterfaceType))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_allowedPhysicalNamePrefixes.Any(prefix => ni.Name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return _allowedPhysicalNameKeywords.Any(keyword => interfaceText.Contains(keyword, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool SupportsIP(NetworkInterface ni)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return ni.Supports(NetworkInterfaceComponent.IPv4) || ni.Supports(NetworkInterfaceComponent.IPv6);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool HasIPv4Gateway(NetworkInterface ni)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return ni.GetIPProperties().GatewayAddresses.Any(g =>
|
||||||
|
g.Address.AddressFamily == AddressFamily.InterNetwork
|
||||||
|
&& !IPAddress.Any.Equals(g.Address)
|
||||||
|
&& !IPAddress.None.Equals(g.Address));
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -348,10 +348,9 @@ public class Utils
|
|||||||
.ToList());
|
.ToList());
|
||||||
}
|
}
|
||||||
|
|
||||||
public static List<string> GetEnumNames<TEnum>() where TEnum : Enum
|
public static List<string> GetEnumNames<TEnum>() where TEnum : struct, Enum
|
||||||
{
|
{
|
||||||
return Enum.GetValues(typeof(TEnum))
|
return Enum.GetValues<TEnum>()
|
||||||
.Cast<TEnum>()
|
|
||||||
.Select(e => e.ToString())
|
.Select(e => e.ToString())
|
||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
@@ -501,6 +500,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
|
||||||
@@ -726,6 +750,40 @@ public class Utils
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Regex match with a timeout guard. Filter patterns can come from user
|
||||||
|
/// input or subscription content while the tested text (remarks, log
|
||||||
|
/// messages) is attacker-influenced, so an evil pattern like (a+)+$
|
||||||
|
/// would otherwise hang the caller (ReDoS). On timeout or invalid
|
||||||
|
/// pattern, fail open (return true) so no node/message is silently
|
||||||
|
/// dropped; the incident is logged.
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsRegexMatch(string? input, string? pattern, int timeoutSeconds = 2)
|
||||||
|
{
|
||||||
|
if (pattern.IsNullOrEmpty())
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (input.IsNullOrEmpty())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return Regex.IsMatch(input, pattern, RegexOptions.None, TimeSpan.FromSeconds(timeoutSeconds));
|
||||||
|
}
|
||||||
|
catch (RegexMatchTimeoutException ex)
|
||||||
|
{
|
||||||
|
Logging.SaveLog("IsRegexMatch timeout", ex);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (ArgumentException ex)
|
||||||
|
{
|
||||||
|
Logging.SaveLog("IsRegexMatch invalid pattern", ex);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#endregion Data Checks
|
#endregion Data Checks
|
||||||
|
|
||||||
#region Speed Test
|
#region Speed Test
|
||||||
@@ -815,6 +873,40 @@ public class Utils
|
|||||||
.Any(ni => ni.Name.Equals(inInterfaceName, StringComparison.OrdinalIgnoreCase));
|
.Any(ni => ni.Name.Equals(inInterfaceName, StringComparison.OrdinalIgnoreCase));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the host holds a globally routable IPv6 address, that is one inside 2000::/3.
|
||||||
|
/// Link-local and unique local addresses are excluded: they never reach the IPv6 internet,
|
||||||
|
/// so a host holding only those has no IPv6 traffic that could bypass the tunnel, and no
|
||||||
|
/// IPv6 path that traffic sent into the tunnel could come back out of.
|
||||||
|
/// </summary>
|
||||||
|
public static bool HasGlobalIPv6Address()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return NetworkInterface.GetAllNetworkInterfaces()
|
||||||
|
.Where(ni => ni.OperationalStatus == OperationalStatus.Up
|
||||||
|
&& ni.NetworkInterfaceType != NetworkInterfaceType.Loopback)
|
||||||
|
.SelectMany(ni => ni.GetIPProperties().UnicastAddresses)
|
||||||
|
.Any(ua => IsGlobalUnicastIPv6(ua.Address));
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsGlobalUnicastIPv6(IPAddress address)
|
||||||
|
{
|
||||||
|
if (address.AddressFamily != AddressFamily.InterNetworkV6)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2000::/3 is the only range currently assigned for global unicast, which leaves out
|
||||||
|
// ::1, fe80::/10, fc00::/7 and ff00::/8 in a single test.
|
||||||
|
return (address.GetAddressBytes()[0] & 0xE0) == 0x20;
|
||||||
|
}
|
||||||
|
|
||||||
#endregion Speed Test
|
#endregion Speed Test
|
||||||
|
|
||||||
#region Miscellaneous
|
#region Miscellaneous
|
||||||
@@ -984,12 +1076,12 @@ public class Utils
|
|||||||
return new Dictionary<string, string>();
|
return new Dictionary<string, string>();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async Task<string?> GetCliWrapOutput(string filePath, string? arg)
|
public static async Task<string?> GetCliWrapOutput(string filePath, string? arg, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
return await GetCliWrapOutput(filePath, arg != null ? new List<string>() { arg } : null);
|
return await GetCliWrapOutput(filePath, arg != null ? new List<string>() { arg } : null, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async Task<string?> GetCliWrapOutput(string filePath, IEnumerable<string>? args)
|
public static async Task<string?> GetCliWrapOutput(string filePath, IEnumerable<string>? args, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -1006,7 +1098,7 @@ public class Utils
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var result = await cmd.ExecuteBufferedAsync();
|
var result = await cmd.ExecuteBufferedAsync(cancellationToken);
|
||||||
if (result.IsSuccess)
|
if (result.IsSuccess)
|
||||||
{
|
{
|
||||||
return result.StandardOutput ?? "";
|
return result.StandardOutput ?? "";
|
||||||
@@ -1038,7 +1130,7 @@ public class Utils
|
|||||||
}
|
}
|
||||||
|
|
||||||
//Check if it is installed by Windows WinGet
|
//Check if it is installed by Windows WinGet
|
||||||
if (IsWindows() && basePath.Contains("Users") && basePath.Contains("WinGet"))
|
if (IsWindows() && basePath.Contains("WinGet"))
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -1348,7 +1440,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
using YamlDotNet.Core;
|
using YamlDotNet.Core;
|
||||||
using YamlDotNet.Serialization;
|
using YamlDotNet.RepresentationModel;
|
||||||
using YamlDotNet.Serialization.NamingConventions;
|
|
||||||
|
|
||||||
namespace ServiceLib.Common;
|
namespace ServiceLib.Common;
|
||||||
|
|
||||||
@@ -13,45 +12,57 @@ public class YamlUtils
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Deserialize
|
/// Deserialize
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <typeparam name="T"></typeparam>
|
|
||||||
/// <param name="str"></param>
|
/// <param name="str"></param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
public static T FromYaml<T>(string str)
|
public static YamlMappingNode FromYaml(string str)
|
||||||
{
|
{
|
||||||
var deserializer = new DeserializerBuilder()
|
//var deserializer = new DeserializerBuilder()
|
||||||
.WithNamingConvention(PascalCaseNamingConvention.Instance)
|
// .WithNamingConvention(PascalCaseNamingConvention.Instance)
|
||||||
.Build();
|
// .Build();
|
||||||
|
//try
|
||||||
|
//{
|
||||||
|
// var obj = deserializer.Deserialize<T>(str);
|
||||||
|
// return obj;
|
||||||
|
//}
|
||||||
|
//catch (Exception ex)
|
||||||
|
//{
|
||||||
|
// Logging.SaveLog(_tag, ex);
|
||||||
|
// return deserializer.Deserialize<T>("");
|
||||||
|
//}
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var obj = deserializer.Deserialize<T>(str);
|
var yaml = new YamlStream();
|
||||||
return obj;
|
using var reader = new StringReader(str);
|
||||||
|
yaml.Load(reader);
|
||||||
|
var rootNode = (YamlMappingNode)yaml.Documents[0].RootNode;
|
||||||
|
return rootNode;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Logging.SaveLog(_tag, ex);
|
Logging.SaveLog(_tag, ex);
|
||||||
return deserializer.Deserialize<T>("");
|
return new YamlMappingNode();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Serialize
|
/// Serialize
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="obj"></param>
|
/// <param name="node"></param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
public static string ToYaml(object? obj)
|
public static string ToYaml(YamlMappingNode? node)
|
||||||
{
|
{
|
||||||
var result = string.Empty;
|
var result = string.Empty;
|
||||||
if (obj == null)
|
if (node == null)
|
||||||
{
|
{
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
var serializer = new SerializerBuilder()
|
|
||||||
.WithNamingConvention(HyphenatedNamingConvention.Instance)
|
|
||||||
.Build();
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
result = serializer.Serialize(obj);
|
var doc = new YamlDocument(node);
|
||||||
|
var stream = new YamlStream(doc);
|
||||||
|
using var writer = new StringWriter();
|
||||||
|
stream.Save(writer, assignAnchors: false);
|
||||||
|
result = writer.ToString();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -64,9 +75,15 @@ public class YamlUtils
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var mergingParser = new MergingParser(new Parser(new StringReader(str)));
|
using var reader = new StringReader(str);
|
||||||
var obj = new DeserializerBuilder().Build().Deserialize(mergingParser);
|
var parser = new Parser(reader);
|
||||||
return ToYaml(obj);
|
var mergingParser = new MergingParser(parser);
|
||||||
|
var yamlStream = new YamlStream();
|
||||||
|
yamlStream.Load(mergingParser);
|
||||||
|
using var writer = new StringWriter();
|
||||||
|
yamlStream.Save(writer, assignAnchors: false);
|
||||||
|
|
||||||
|
return writer.ToString();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,5 +5,4 @@ public enum ERuleMode
|
|||||||
Rule = 0,
|
Rule = 0,
|
||||||
Global = 1,
|
Global = 1,
|
||||||
Direct = 2,
|
Direct = 2,
|
||||||
Unchanged = 3
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,5 +10,6 @@ public enum ETransport
|
|||||||
h2,
|
h2,
|
||||||
http,
|
http,
|
||||||
quic,
|
quic,
|
||||||
grpc
|
grpc,
|
||||||
|
xdrive
|
||||||
}
|
}
|
||||||
|
|||||||
+83
-18
@@ -42,11 +42,13 @@ public class Global
|
|||||||
public const string SingboxFakeIPFilterFileName = NamespaceSample + "singbox_fakeip_filter";
|
public const string SingboxFakeIPFilterFileName = NamespaceSample + "singbox_fakeip_filter";
|
||||||
public const string ChromeRootCertFileName = NamespaceSample + "chrome_roots_pem";
|
public const string ChromeRootCertFileName = NamespaceSample + "chrome_roots_pem";
|
||||||
public const string MozillaRootCertFileName = NamespaceSample + "mozilla_roots_pem";
|
public const string MozillaRootCertFileName = NamespaceSample + "mozilla_roots_pem";
|
||||||
|
public const string TaskXmlTemplateFileName = NamespaceSample + "TaskXmlTemplate";
|
||||||
|
|
||||||
public const string ChromeRootProvider = "chrome";
|
public const string ChromeRootProvider = "chrome";
|
||||||
public const string MozillaRootProvider = "mozilla";
|
public const string MozillaRootProvider = "mozilla";
|
||||||
|
|
||||||
public const string DefaultSecurity = "auto";
|
public const string DefaultSecurity = "auto";
|
||||||
|
public const string BindInterfaceAuto = "AUTO";
|
||||||
public const string DefaultNetwork = "raw";
|
public const string DefaultNetwork = "raw";
|
||||||
public const string RawHeaderHttp = "http";
|
public const string RawHeaderHttp = "http";
|
||||||
public const string None = "none";
|
public const string None = "none";
|
||||||
@@ -92,16 +94,27 @@ public class Global
|
|||||||
public const string XrayLocalAsset = "XRAY_LOCATION_ASSET";
|
public const string XrayLocalAsset = "XRAY_LOCATION_ASSET";
|
||||||
public const string XrayLocalCert = "XRAY_LOCATION_CERT";
|
public const string XrayLocalCert = "XRAY_LOCATION_CERT";
|
||||||
public const int SpeedTestPageSize = 1000;
|
public const int SpeedTestPageSize = 1000;
|
||||||
|
public const int SpeedTestConcurrencyCountMin = 10;
|
||||||
public const string LinuxBash = "/bin/bash";
|
public const string LinuxBash = "/bin/bash";
|
||||||
public const string StringTrue = "true";
|
public const string StringTrue = "true";
|
||||||
public const string StringFalse = "false";
|
public const string StringFalse = "false";
|
||||||
public const int SqliteMaxBatchSize = 10000;
|
public const int SqliteMaxBatchSize = 10000;
|
||||||
|
public static readonly TimeSpan LocalFetch = TimeSpan.FromSeconds(5);
|
||||||
|
public static readonly TimeSpan DirectFetch = TimeSpan.FromSeconds(10);
|
||||||
|
public static readonly TimeSpan ProxyFetch = TimeSpan.FromSeconds(30);
|
||||||
|
public static readonly TimeSpan DirectDownloadConnect = TimeSpan.FromSeconds(5);
|
||||||
|
public static readonly TimeSpan ProxyDownloadConnect = TimeSpan.FromSeconds(10);
|
||||||
|
|
||||||
public const string SingboxDirectDNSTag = "direct_dns";
|
public const string SingboxDirectDNSTagPrefix = "direct-dns-";
|
||||||
public const string SingboxRemoteDNSTag = "remote_dns";
|
public const string SingboxRemoteDNSTagPrefix = "remote-dns-";
|
||||||
public const string SingboxLocalDNSTag = "local_local";
|
public const string SingboxDirectDNSTag = "direct-dns-1";
|
||||||
public const string SingboxHostsDNSTag = "hosts_dns";
|
public const string SingboxRemoteDNSTag = "remote-dns-1";
|
||||||
public const string SingboxFakeDNSTag = "fake_dns";
|
public const string SingboxDirectDNSTagTemplate = "direct-dns-{0}";
|
||||||
|
public const string SingboxRemoteDNSTagTemplate = "remote-dns-{0}";
|
||||||
|
public const string SingboxLocalDNSTag = "local-local";
|
||||||
|
public const string SingboxHostsDNSTag = "hosts-dns";
|
||||||
|
public const string SingboxFakeDNSTag = "fake-dns";
|
||||||
|
public const string SingboxSrsDownloadHttpClientTag = "srs-download-http-client";
|
||||||
|
|
||||||
public const int Hysteria2DefaultHopInt = 30;
|
public const int Hysteria2DefaultHopInt = 30;
|
||||||
public const string PolicyGroupExcludeKeywords = @"剩余|过期|到期|重置|[Rr]emaining|[Ee]xpir|[Rr]eset";
|
public const string PolicyGroupExcludeKeywords = @"剩余|过期|到期|重置|[Rr]emaining|[Ee]xpir|[Rr]eset";
|
||||||
@@ -250,7 +263,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 =
|
||||||
@@ -323,7 +337,8 @@ public class Global
|
|||||||
"kcp",
|
"kcp",
|
||||||
"grpc",
|
"grpc",
|
||||||
"ws",
|
"ws",
|
||||||
"httpupgrade"
|
"httpupgrade",
|
||||||
|
"xdrive"
|
||||||
];
|
];
|
||||||
|
|
||||||
public static readonly List<string> KcpHeaderTypes =
|
public static readonly List<string> KcpHeaderTypes =
|
||||||
@@ -360,6 +375,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,
|
||||||
];
|
];
|
||||||
@@ -375,6 +391,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,
|
||||||
];
|
];
|
||||||
@@ -439,6 +456,18 @@ public class Global
|
|||||||
"stream-one"
|
"stream-one"
|
||||||
];
|
];
|
||||||
|
|
||||||
|
public static readonly List<string> XdriveServices = ["google-drive", "s3", "local"];
|
||||||
|
|
||||||
|
public static readonly List<string> XdriveProviders =
|
||||||
|
[
|
||||||
|
"Cloudflare R2",
|
||||||
|
"Google Drive",
|
||||||
|
"AWS S3 (Global)",
|
||||||
|
"Yandex Cloud S3",
|
||||||
|
"ArvanCloud S3",
|
||||||
|
"Custom S3"
|
||||||
|
];
|
||||||
|
|
||||||
public static readonly List<string> DomainStrategy =
|
public static readonly List<string> DomainStrategy =
|
||||||
[
|
[
|
||||||
"AsIs",
|
"AsIs",
|
||||||
@@ -484,16 +513,36 @@ public class Global
|
|||||||
"localhost"
|
"localhost"
|
||||||
];
|
];
|
||||||
|
|
||||||
|
public static readonly List<string> DomainDNS4PreResolveAddress =
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
"https://doh.pub/dns-query",
|
||||||
|
"https://dns.alidns.com/dns-query",
|
||||||
|
"119.29.29.29",
|
||||||
|
"223.5.5.5",
|
||||||
|
"localhost",
|
||||||
|
"https://cloudflare-dns.com/dns-query",
|
||||||
|
"https://dns.google/dns-query",
|
||||||
|
"8.8.8.8",
|
||||||
|
"1.1.1.1",
|
||||||
|
];
|
||||||
|
|
||||||
|
public static readonly List<LanguageOption> LanguageOptions =
|
||||||
|
[
|
||||||
|
new("zh-Hans", "简体中文"),
|
||||||
|
new("zh-Hant", "繁體中文"),
|
||||||
|
new("en", "English"),
|
||||||
|
new("fa", "فارسی"),
|
||||||
|
new("fr", "Français"),
|
||||||
|
new("ru", "Русский"),
|
||||||
|
new("hu", "Magyar"),
|
||||||
|
new("id", "Bahasa Indonesia"),
|
||||||
|
new("az", "Azərbaycan dili")
|
||||||
|
];
|
||||||
|
|
||||||
public static readonly List<string> Languages =
|
public static readonly List<string> Languages =
|
||||||
[
|
[
|
||||||
"zh-Hans",
|
.. LanguageOptions.Select(t => t.Value)
|
||||||
"zh-Hant",
|
|
||||||
"en",
|
|
||||||
"fa",
|
|
||||||
"fr",
|
|
||||||
"ru",
|
|
||||||
"hu",
|
|
||||||
"id"
|
|
||||||
];
|
];
|
||||||
|
|
||||||
public static readonly List<string> Alpns =
|
public static readonly List<string> Alpns =
|
||||||
@@ -572,6 +621,16 @@ public class Global
|
|||||||
"mixed"
|
"mixed"
|
||||||
];
|
];
|
||||||
|
|
||||||
|
public const string TunDnsModeDefault = "Default";
|
||||||
|
|
||||||
|
public static readonly List<string> TunDnsModes =
|
||||||
|
[
|
||||||
|
TunDnsModeDefault,
|
||||||
|
"disabled",
|
||||||
|
"native",
|
||||||
|
"hijack"
|
||||||
|
];
|
||||||
|
|
||||||
public static readonly List<string> PresetMsgFilters =
|
public static readonly List<string> PresetMsgFilters =
|
||||||
[
|
[
|
||||||
"proxy",
|
"proxy",
|
||||||
@@ -636,10 +695,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" },
|
||||||
@@ -738,6 +797,12 @@ public class Global
|
|||||||
"11.0.0.0/8",
|
"11.0.0.0/8",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
public static readonly List<string> FakeIPv6Ranges =
|
||||||
|
[
|
||||||
|
"fc00::/18",
|
||||||
|
"2001:2::/48",
|
||||||
|
];
|
||||||
|
|
||||||
public static readonly List<string> RootCertProviders =
|
public static readonly List<string> RootCertProviders =
|
||||||
[
|
[
|
||||||
"system",
|
"system",
|
||||||
|
|||||||
@@ -6,10 +6,6 @@ global using System.Diagnostics;
|
|||||||
global using System.Net;
|
global using System.Net;
|
||||||
global using System.Net.NetworkInformation;
|
global using System.Net.NetworkInformation;
|
||||||
global using System.Net.Sockets;
|
global using System.Net.Sockets;
|
||||||
global using ReactiveUI.Primitives;
|
|
||||||
global using ReactiveUI.Primitives.Concurrency;
|
|
||||||
global using ReactiveUI.Primitives.Disposables;
|
|
||||||
global using ReactiveUI.Primitives.Signals;
|
|
||||||
global using System.Reflection;
|
global using System.Reflection;
|
||||||
global using System.Runtime.InteropServices;
|
global using System.Runtime.InteropServices;
|
||||||
global using System.Runtime.Versioning;
|
global using System.Runtime.Versioning;
|
||||||
@@ -21,6 +17,11 @@ global using System.Text.Json.Nodes;
|
|||||||
global using System.Text.Json.Serialization;
|
global using System.Text.Json.Serialization;
|
||||||
global using System.Text.RegularExpressions;
|
global using System.Text.RegularExpressions;
|
||||||
global using ReactiveUI;
|
global using ReactiveUI;
|
||||||
|
global using ReactiveUI.Primitives;
|
||||||
|
global using ReactiveUI.Primitives.Concurrency;
|
||||||
|
global using ReactiveUI.Primitives.Disposables;
|
||||||
|
global using ReactiveUI.Primitives.Extensions;
|
||||||
|
global using ReactiveUI.Primitives.Signals;
|
||||||
global using ReactiveUI.SourceGenerators;
|
global using ReactiveUI.SourceGenerators;
|
||||||
global using ServiceLib.Base;
|
global using ServiceLib.Base;
|
||||||
global using ServiceLib.Common;
|
global using ServiceLib.Common;
|
||||||
@@ -41,5 +42,3 @@ global using ServiceLib.Services;
|
|||||||
global using ServiceLib.Services.CoreConfig;
|
global using ServiceLib.Services.CoreConfig;
|
||||||
global using ServiceLib.Services.Statistics;
|
global using ServiceLib.Services.Statistics;
|
||||||
global using SQLite;
|
global using SQLite;
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
using System.Security.Principal;
|
|
||||||
|
|
||||||
namespace ServiceLib.Handler;
|
namespace ServiceLib.Handler;
|
||||||
|
|
||||||
public static class AutoStartupHandler
|
public static class AutoStartupHandler
|
||||||
@@ -87,6 +85,7 @@ public static class AutoStartupHandler
|
|||||||
[SupportedOSPlatform("windows")]
|
[SupportedOSPlatform("windows")]
|
||||||
public static void AutoStartTaskService(string taskName, string fileName, string description)
|
public static void AutoStartTaskService(string taskName, string fileName, string description)
|
||||||
{
|
{
|
||||||
|
#if false
|
||||||
if (taskName.IsNullOrEmpty())
|
if (taskName.IsNullOrEmpty())
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
@@ -117,6 +116,78 @@ public static class AutoStartupHandler
|
|||||||
task.Actions.Add(new Microsoft.Win32.TaskScheduler.ExecAction(fileName.AppendQuotes(), null, Path.GetDirectoryName(fileName)));
|
task.Actions.Add(new Microsoft.Win32.TaskScheduler.ExecAction(fileName.AppendQuotes(), null, Path.GetDirectoryName(fileName)));
|
||||||
|
|
||||||
taskService.RootFolder.RegisterTaskDefinition(taskName, task);
|
taskService.RootFolder.RegisterTaskDefinition(taskName, task);
|
||||||
|
#endif
|
||||||
|
if (string.IsNullOrEmpty(taskName))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
RunSchtasks($"/Delete /TN \"{taskName}\" /F");
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(fileName))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var workingDir = Path.GetDirectoryName(fileName) ?? string.Empty;
|
||||||
|
|
||||||
|
var taskXml = EmbedUtils.GetEmbedText(Global.TaskXmlTemplateFileName);
|
||||||
|
|
||||||
|
taskXml = taskXml
|
||||||
|
.Replace("{{DESCRIPTION}}", System.Security.SecurityElement.Escape(description ?? ""))
|
||||||
|
.Replace("{{EXEC_PATH}}", System.Security.SecurityElement.Escape(fileName ?? ""))
|
||||||
|
.Replace("{{ARGUMENTS}}", string.Empty)
|
||||||
|
.Replace("{{WORKING_DIRECTORY}}", System.Security.SecurityElement.Escape(workingDir ?? ""));
|
||||||
|
|
||||||
|
var taskXmlFileName = Utils.GetTempPath($"{Utils.GetGuid(false)}.xml");
|
||||||
|
|
||||||
|
File.WriteAllText(taskXmlFileName, taskXml);
|
||||||
|
|
||||||
|
RunSchtasks(
|
||||||
|
"/Create",
|
||||||
|
"/TN", taskName,
|
||||||
|
"/XML", taskXmlFileName,
|
||||||
|
"/F"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool RunSchtasks(params string[] arguments)
|
||||||
|
{
|
||||||
|
var psi = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = "schtasks.exe",
|
||||||
|
UseShellExecute = false,
|
||||||
|
CreateNoWindow = true,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var argument in arguments)
|
||||||
|
{
|
||||||
|
psi.ArgumentList.Add(argument);
|
||||||
|
}
|
||||||
|
|
||||||
|
using var process = Process.Start(psi);
|
||||||
|
|
||||||
|
if (process == null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
process.WaitForExit();
|
||||||
|
|
||||||
|
var output = process.StandardOutput.ReadToEnd();
|
||||||
|
var error = process.StandardError.ReadToEnd();
|
||||||
|
if (!string.IsNullOrEmpty(output))
|
||||||
|
{
|
||||||
|
Logging.SaveLog($"RunSchtasks output: {output}");
|
||||||
|
}
|
||||||
|
if (!string.IsNullOrEmpty(error))
|
||||||
|
{
|
||||||
|
Logging.SaveLog($"RunSchtasks error: {error}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return process.ExitCode == 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string GetAutoRunNameWindows()
|
private static string GetAutoRunNameWindows()
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ 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(),
|
||||||
ProtectCoreTypeList = config.TunModeItem.EnableTun ? [ECoreType.Xray, ECoreType.sing_box] : []
|
ProtectCoreTypeList = config.TunModeItem.EnableTun ? [ECoreType.Xray, ECoreType.sing_box] : []
|
||||||
};
|
};
|
||||||
var validatorResult = NodeValidatorResult.Empty();
|
var validatorResult = NodeValidatorResult.Empty();
|
||||||
@@ -119,17 +121,28 @@ public class CoreConfigContextBuilder
|
|||||||
&& !Utils.IsLocalIP(context.AppConfig.CoreBasicItem.SendThrough))
|
&& !Utils.IsLocalIP(context.AppConfig.CoreBasicItem.SendThrough))
|
||||||
{
|
{
|
||||||
validatorResult.Warnings.Add(string.Format(ResUI.MsgInvalidProperty, ResUI.TbSettingsSendThrough));
|
validatorResult.Warnings.Add(string.Format(ResUI.MsgInvalidProperty, ResUI.TbSettingsSendThrough));
|
||||||
var appConfig = JsonUtils.DeepCopy(config);
|
var appConfig = JsonUtils.DeepCopy(context.AppConfig);
|
||||||
appConfig.CoreBasicItem.SendThrough = string.Empty;
|
appConfig.CoreBasicItem.SendThrough = string.Empty;
|
||||||
context = context with { AppConfig = appConfig };
|
context = context with { AppConfig = appConfig };
|
||||||
}
|
}
|
||||||
if (!context.AppConfig.CoreBasicItem.BindInterface.IsNullOrEmpty()
|
var bindInterface = context.AppConfig.CoreBasicItem.BindInterface?.TrimEx();
|
||||||
&& (!Utils.ContainsInterfaceName(context.AppConfig.CoreBasicItem.BindInterface)
|
if (bindInterface.IsNotEmpty())
|
||||||
|| !(context.IsTunEnabled || context.IsWindows)))
|
{
|
||||||
|
if (NetworkUtils.IsAutoBindInterface(bindInterface))
|
||||||
|
{
|
||||||
|
bindInterface = NetworkUtils.GetDefaultPhysicalInterfaceName()?.TrimEx();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bindInterface.IsNullOrEmpty()
|
||||||
|
|| !Utils.ContainsInterfaceName(bindInterface)
|
||||||
|
|| !(context.IsTunEnabled || context.IsWindows))
|
||||||
{
|
{
|
||||||
validatorResult.Warnings.Add(string.Format(ResUI.MsgInvalidProperty, ResUI.TbSettingsBindInterface));
|
validatorResult.Warnings.Add(string.Format(ResUI.MsgInvalidProperty, ResUI.TbSettingsBindInterface));
|
||||||
var appConfig = JsonUtils.DeepCopy(config);
|
bindInterface = string.Empty;
|
||||||
appConfig.CoreBasicItem.BindInterface = string.Empty;
|
}
|
||||||
|
|
||||||
|
var appConfig = JsonUtils.DeepCopy(context.AppConfig);
|
||||||
|
appConfig.CoreBasicItem.BindInterface = bindInterface;
|
||||||
context = context with { AppConfig = appConfig };
|
context = context with { AppConfig = appConfig };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
using System.Data;
|
|
||||||
|
|
||||||
namespace ServiceLib.Handler;
|
namespace ServiceLib.Handler;
|
||||||
|
|
||||||
public static class ConfigHandler
|
public static class ConfigHandler
|
||||||
@@ -104,6 +102,21 @@ public static class ConfigHandler
|
|||||||
config.UiItem ??= new();
|
config.UiItem ??= new();
|
||||||
config.UiItem.MainColumnItem ??= [];
|
config.UiItem.MainColumnItem ??= [];
|
||||||
config.UiItem.WindowSizeItem ??= [];
|
config.UiItem.WindowSizeItem ??= [];
|
||||||
|
if (config.UiItem.FrozenColumnCount == null)
|
||||||
|
{
|
||||||
|
var lvColumnItem = config.UiItem.MainColumnItem.OrderBy(t => t.Index).ToList();
|
||||||
|
if (lvColumnItem.Count == 0
|
||||||
|
|| (lvColumnItem.Count >= 2
|
||||||
|
&& lvColumnItem[0].Name.Equals("ConfigType")
|
||||||
|
&& lvColumnItem[1].Name.Equals("Remarks")))
|
||||||
|
{
|
||||||
|
config.UiItem.FrozenColumnCount = 2;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
config.UiItem.FrozenColumnCount = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (config.UiItem.CurrentLanguage.IsNullOrEmpty())
|
if (config.UiItem.CurrentLanguage.IsNullOrEmpty())
|
||||||
{
|
{
|
||||||
@@ -117,6 +130,8 @@ public static class ConfigHandler
|
|||||||
config.SimpleDNSItem ??= InitBuiltinSimpleDNS();
|
config.SimpleDNSItem ??= InitBuiltinSimpleDNS();
|
||||||
config.SimpleDNSItem.BlockAAAAQuery ??= false;
|
config.SimpleDNSItem.BlockAAAAQuery ??= false;
|
||||||
config.SimpleDNSItem.FakeIPRange ??= Global.FakeIPRanges.FirstOrDefault();
|
config.SimpleDNSItem.FakeIPRange ??= Global.FakeIPRanges.FirstOrDefault();
|
||||||
|
config.SimpleDNSItem.FakeIPv6 ??= false;
|
||||||
|
config.SimpleDNSItem.FakeIPv6Range ??= Global.FakeIPv6Ranges.First();
|
||||||
config.SimpleDNSItem.GlobalFakeIp ??= true;
|
config.SimpleDNSItem.GlobalFakeIp ??= true;
|
||||||
config.SimpleDNSItem.BootstrapDNS ??= Global.DomainPureIPDNSAddress.FirstOrDefault();
|
config.SimpleDNSItem.BootstrapDNS ??= Global.DomainPureIPDNSAddress.FirstOrDefault();
|
||||||
config.SimpleDNSItem.ServeStale ??= false;
|
config.SimpleDNSItem.ServeStale ??= false;
|
||||||
@@ -136,10 +151,7 @@ public static class ConfigHandler
|
|||||||
{
|
{
|
||||||
config.SpeedTestItem.SpeedPingTestUrl = Global.SpeedPingTestUrls.First();
|
config.SpeedTestItem.SpeedPingTestUrl = Global.SpeedPingTestUrls.First();
|
||||||
}
|
}
|
||||||
if (config.SpeedTestItem.MixedConcurrencyCount < 1)
|
config.SpeedTestItem.MixedConcurrencyCount = Math.Max(config.SpeedTestItem.MixedConcurrencyCount, Global.SpeedTestConcurrencyCountMin);
|
||||||
{
|
|
||||||
config.SpeedTestItem.MixedConcurrencyCount = 5;
|
|
||||||
}
|
|
||||||
if (config.SpeedTestItem.UdpTestTarget.IsNullOrEmpty())
|
if (config.SpeedTestItem.UdpTestTarget.IsNullOrEmpty())
|
||||||
{
|
{
|
||||||
config.SpeedTestItem.UdpTestTarget = Global.UdpTestTargets.First();
|
config.SpeedTestItem.UdpTestTarget = Global.UdpTestTargets.First();
|
||||||
@@ -295,6 +307,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;
|
||||||
@@ -471,12 +484,12 @@ public static class ConfigHandler
|
|||||||
/// Supports moving to top, up, down, bottom or specific position
|
/// Supports moving to top, up, down, bottom or specific position
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="config">Current configuration</param>
|
/// <param name="config">Current configuration</param>
|
||||||
/// <param name="lstProfile">List of server profiles</param>
|
/// <param name="lstProfile">List of server profile index ids</param>
|
||||||
/// <param name="index">Index of the server to move</param>
|
/// <param name="index">Index of the server to move</param>
|
||||||
/// <param name="eMove">Direction to move the server</param>
|
/// <param name="eMove">Direction to move the server</param>
|
||||||
/// <param name="pos">Target position when using EMove.Position</param>
|
/// <param name="pos">Target position when using EMove.Position</param>
|
||||||
/// <returns>0 if successful, -1 if failed</returns>
|
/// <returns>0 if successful, -1 if failed</returns>
|
||||||
public static async Task<int> MoveServer(Config config, List<ProfileItem> lstProfile, int index, EMove eMove, int pos = -1)
|
public static async Task<int> MoveServer(Config config, List<string> lstProfile, int index, EMove eMove, int pos = -1)
|
||||||
{
|
{
|
||||||
var count = lstProfile.Count;
|
var count = lstProfile.Count;
|
||||||
if (index < 0 || index > lstProfile.Count - 1)
|
if (index < 0 || index > lstProfile.Count - 1)
|
||||||
@@ -486,7 +499,7 @@ public static class ConfigHandler
|
|||||||
|
|
||||||
for (var i = 0; i < lstProfile.Count; i++)
|
for (var i = 0; i < lstProfile.Count; i++)
|
||||||
{
|
{
|
||||||
ProfileExManager.Instance.SetSort(lstProfile[i].IndexId, (i + 1) * 10);
|
ProfileExManager.Instance.SetSort(lstProfile[i], (i + 1) * 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
var sort = 0;
|
var sort = 0;
|
||||||
@@ -498,7 +511,7 @@ public static class ConfigHandler
|
|||||||
{
|
{
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
sort = ProfileExManager.Instance.GetSort(lstProfile.First().IndexId) - 1;
|
sort = ProfileExManager.Instance.GetSort(lstProfile.First()) - 1;
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -508,7 +521,7 @@ public static class ConfigHandler
|
|||||||
{
|
{
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
sort = ProfileExManager.Instance.GetSort(lstProfile[index - 1].IndexId) - 1;
|
sort = ProfileExManager.Instance.GetSort(lstProfile[index - 1]) - 1;
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -519,7 +532,7 @@ public static class ConfigHandler
|
|||||||
{
|
{
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
sort = ProfileExManager.Instance.GetSort(lstProfile[index + 1].IndexId) + 1;
|
sort = ProfileExManager.Instance.GetSort(lstProfile[index + 1]) + 1;
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -529,7 +542,7 @@ public static class ConfigHandler
|
|||||||
{
|
{
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
sort = ProfileExManager.Instance.GetSort(lstProfile[^1].IndexId) + 1;
|
sort = ProfileExManager.Instance.GetSort(lstProfile[^1]) + 1;
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -538,10 +551,71 @@ public static class ConfigHandler
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
ProfileExManager.Instance.SetSort(lstProfile[index].IndexId, sort);
|
ProfileExManager.Instance.SetSort(lstProfile[index], sort);
|
||||||
return await Task.FromResult(0);
|
return await Task.FromResult(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static Task<int> MoveServersTo(
|
||||||
|
Config config,
|
||||||
|
IReadOnlyList<string> orderedProfileIds,
|
||||||
|
IReadOnlyList<int> sourceIndices,
|
||||||
|
int insertIndex)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(orderedProfileIds);
|
||||||
|
ArgumentNullException.ThrowIfNull(sourceIndices);
|
||||||
|
|
||||||
|
if (orderedProfileIds.Count == 0 ||
|
||||||
|
sourceIndices.Count == 0 ||
|
||||||
|
insertIndex < 0 ||
|
||||||
|
insertIndex > orderedProfileIds.Count)
|
||||||
|
{
|
||||||
|
return Task.FromResult(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
int[] movingIndices = sourceIndices
|
||||||
|
.Distinct()
|
||||||
|
.OrderBy(index => index)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
if (movingIndices.Length != sourceIndices.Count ||
|
||||||
|
movingIndices.Any(index =>
|
||||||
|
index < 0 || index >= orderedProfileIds.Count))
|
||||||
|
{
|
||||||
|
return Task.FromResult(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
var movingIndexSet = movingIndices.ToHashSet();
|
||||||
|
|
||||||
|
var movingIds = movingIndices
|
||||||
|
.Select(index => orderedProfileIds[index])
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var remainingIds = orderedProfileIds
|
||||||
|
.Where((_, index) => !movingIndexSet.Contains(index))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (insertIndex > remainingIds.Count)
|
||||||
|
{
|
||||||
|
return Task.FromResult(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
remainingIds.InsertRange(insertIndex, movingIds);
|
||||||
|
|
||||||
|
if (remainingIds.Count != orderedProfileIds.Count)
|
||||||
|
{
|
||||||
|
return Task.FromResult(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var index = 0; index < remainingIds.Count; index++)
|
||||||
|
{
|
||||||
|
ProfileExManager.Instance.SetSort(
|
||||||
|
remainingIds[index],
|
||||||
|
(index + 1) * 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.FromResult(0);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Add a custom server configuration from a file
|
/// Add a custom server configuration from a file
|
||||||
/// Copies the configuration file to the app's config directory
|
/// Copies the configuration file to the app's config directory
|
||||||
@@ -995,6 +1069,25 @@ 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();
|
||||||
|
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
|
||||||
@@ -1528,7 +1621,7 @@ public static class ConfigHandler
|
|||||||
p != null &&
|
p != null &&
|
||||||
p.IsValid() &&
|
p.IsValid() &&
|
||||||
(!p.ConfigType.IsComplexType() || p.ConfigType == EConfigType.Outbound) &&
|
(!p.ConfigType.IsComplexType() || p.ConfigType == EConfigType.Outbound) &&
|
||||||
(extraItem.Filter.IsNullOrEmpty() || Regex.IsMatch(p.Remarks, extraItem.Filter))
|
Utils.IsRegexMatch(p.Remarks, extraItem.Filter)
|
||||||
)
|
)
|
||||||
.ToList() ?? [];
|
.ToList() ?? [];
|
||||||
if (matchedChildProfiles.Count == 0)
|
if (matchedChildProfiles.Count == 0)
|
||||||
@@ -1635,11 +1728,16 @@ public static class ConfigHandler
|
|||||||
}
|
}
|
||||||
|
|
||||||
var subFilter = string.Empty;
|
var subFilter = string.Empty;
|
||||||
|
var preResolver = string.Empty;
|
||||||
if (isSub && subid.IsNotEmpty())
|
if (isSub && subid.IsNotEmpty())
|
||||||
{
|
{
|
||||||
subFilter = (await AppManager.Instance.GetSubItem(subid))?.Filter ?? "";
|
subFilter = (await AppManager.Instance.GetSubItem(subid))?.Filter ?? "";
|
||||||
|
preResolver = (await AppManager.Instance.GetSubItem(subid))?.PreResolver ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var dnsClient = preResolver.IsNullOrEmpty() ? null : EndpointResolveManager.BuildDnsClient(preResolver);
|
||||||
|
var dnsCache = new Dictionary<string, string>();
|
||||||
|
|
||||||
var countServers = 0;
|
var countServers = 0;
|
||||||
List<ProfileItem> lstAdd = [];
|
List<ProfileItem> lstAdd = [];
|
||||||
var arrData = strData.Split(Environment.NewLine.ToCharArray()).Where(t => !t.IsNullOrEmpty());
|
var arrData = strData.Split(Environment.NewLine.ToCharArray()).Where(t => !t.IsNullOrEmpty());
|
||||||
@@ -1667,7 +1765,7 @@ public static class ConfigHandler
|
|||||||
//exist sub items //filter
|
//exist sub items //filter
|
||||||
if (isSub && subid.IsNotEmpty() && subFilter.IsNotEmpty())
|
if (isSub && subid.IsNotEmpty() && subFilter.IsNotEmpty())
|
||||||
{
|
{
|
||||||
if (!Regex.IsMatch(profileItem.Remarks, subFilter))
|
if (!Utils.IsRegexMatch(profileItem.Remarks, subFilter))
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -1675,6 +1773,21 @@ public static class ConfigHandler
|
|||||||
profileItem.Subid = subid;
|
profileItem.Subid = subid;
|
||||||
profileItem.IsSub = isSub;
|
profileItem.IsSub = isSub;
|
||||||
|
|
||||||
|
if (dnsClient is not null && profileItem.Address.IsNotEmpty() && !Utils.IsIpAddress(profileItem.Address))
|
||||||
|
{
|
||||||
|
if (!dnsCache.TryGetValue(profileItem.Address, out var resolvedAddress))
|
||||||
|
{
|
||||||
|
var ipList = await dnsClient.ResolveIpv46Async(profileItem.Address);
|
||||||
|
resolvedAddress = ipList.FirstOrDefault()?.ToString() ?? profileItem.Address;
|
||||||
|
dnsCache[profileItem.Address] = resolvedAddress;
|
||||||
|
}
|
||||||
|
if (!resolvedAddress.IsNullOrEmpty())
|
||||||
|
{
|
||||||
|
profileItem.Address = resolvedAddress;
|
||||||
|
}
|
||||||
|
// TODO: Override the host and sni if empty
|
||||||
|
}
|
||||||
|
|
||||||
var addStatus = profileItem.ConfigType switch
|
var addStatus = profileItem.ConfigType switch
|
||||||
{
|
{
|
||||||
EConfigType.VMess => await AddVMessServer(config, profileItem, false),
|
EConfigType.VMess => await AddVMessServer(config, profileItem, false),
|
||||||
@@ -1687,6 +1800,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,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1756,7 +1870,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;
|
||||||
@@ -1802,7 +1916,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;
|
||||||
@@ -1816,7 +1930,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)
|
||||||
@@ -1826,6 +1941,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++;
|
||||||
@@ -2049,7 +2165,6 @@ public static class ConfigHandler
|
|||||||
{
|
{
|
||||||
lstOriSub = await AppManager.Instance.ProfileItems(subid);
|
lstOriSub = await AppManager.Instance.ProfileItems(subid);
|
||||||
activeProfile = lstOriSub?.FirstOrDefault(t => t.IndexId == config.IndexId);
|
activeProfile = lstOriSub?.FirstOrDefault(t => t.IndexId == config.IndexId);
|
||||||
await RemoveServersViaSubid(config, subid, true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var counter = 0;
|
var counter = 0;
|
||||||
@@ -2109,6 +2224,11 @@ public static class ConfigHandler
|
|||||||
counter = await AddBatchServers4Custom(config, strData, subid, isSub);
|
counter = await AddBatchServers4Custom(config, strData, subid, isSub);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (counter > 0 && lstOriSub is { Count: > 0 })
|
||||||
|
{
|
||||||
|
await RemoveServers(config, lstOriSub.Where(item => item.IsSub).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
//Select active node
|
//Select active node
|
||||||
if (activeProfile != null)
|
if (activeProfile != null)
|
||||||
{
|
{
|
||||||
@@ -2202,6 +2322,7 @@ public static class ConfigHandler
|
|||||||
item.Enabled = subItem.Enabled;
|
item.Enabled = subItem.Enabled;
|
||||||
item.AutoUpdateInterval = subItem.AutoUpdateInterval;
|
item.AutoUpdateInterval = subItem.AutoUpdateInterval;
|
||||||
item.UserAgent = subItem.UserAgent;
|
item.UserAgent = subItem.UserAgent;
|
||||||
|
item.RequestHeaders = subItem.RequestHeaders;
|
||||||
item.Sort = subItem.Sort;
|
item.Sort = subItem.Sort;
|
||||||
item.Filter = subItem.Filter;
|
item.Filter = subItem.Filter;
|
||||||
item.UpdateTime = subItem.UpdateTime;
|
item.UpdateTime = subItem.UpdateTime;
|
||||||
@@ -2211,6 +2332,7 @@ public static class ConfigHandler
|
|||||||
item.PreSocksPort = subItem.PreSocksPort;
|
item.PreSocksPort = subItem.PreSocksPort;
|
||||||
item.Memo = subItem.Memo;
|
item.Memo = subItem.Memo;
|
||||||
item.CustomCoreType = subItem.CustomCoreType;
|
item.CustomCoreType = subItem.CustomCoreType;
|
||||||
|
item.PreResolver = subItem.PreResolver;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.Id.IsNullOrEmpty())
|
if (item.Id.IsNullOrEmpty())
|
||||||
@@ -2537,8 +2659,8 @@ public static class ConfigHandler
|
|||||||
/// <returns>0 if successful</returns>
|
/// <returns>0 if successful</returns>
|
||||||
public static async Task<int> InitExternalRouting(Config config, bool blImportAdvancedRules = false)
|
public static async Task<int> InitExternalRouting(Config config, bool blImportAdvancedRules = false)
|
||||||
{
|
{
|
||||||
var downloadHandle = new DownloadService();
|
using var downloadHandle = await DownloadService.CreateDownloadServiceWithCheck();
|
||||||
var templateContent = await downloadHandle.TryDownloadString(config.ConstItem.RouteRulesTemplateSourceUrl, true, "");
|
var templateContent = await downloadHandle.TryDownloadString(config.ConstItem.RouteRulesTemplateSourceUrl);
|
||||||
if (templateContent.IsNullOrEmpty())
|
if (templateContent.IsNullOrEmpty())
|
||||||
{
|
{
|
||||||
return await InitBuiltinRouting(config, blImportAdvancedRules); // fallback
|
return await InitBuiltinRouting(config, blImportAdvancedRules); // fallback
|
||||||
@@ -2567,7 +2689,7 @@ public static class ConfigHandler
|
|||||||
|
|
||||||
var ruleSetsString = !item.RuleSet.IsNullOrEmpty()
|
var ruleSetsString = !item.RuleSet.IsNullOrEmpty()
|
||||||
? item.RuleSet
|
? item.RuleSet
|
||||||
: await downloadHandle.TryDownloadString(item.Url, true, "");
|
: await downloadHandle.TryDownloadString(item.Url);
|
||||||
|
|
||||||
if (ruleSetsString.IsNullOrEmpty())
|
if (ruleSetsString.IsNullOrEmpty())
|
||||||
{
|
{
|
||||||
@@ -2765,8 +2887,8 @@ public static class ConfigHandler
|
|||||||
{
|
{
|
||||||
var currentItem = await AppManager.Instance.GetDNSItem(type);
|
var currentItem = await AppManager.Instance.GetDNSItem(type);
|
||||||
|
|
||||||
var downloadHandle = new DownloadService();
|
using var downloadHandle = await DownloadService.CreateDownloadServiceWithCheck();
|
||||||
var templateContent = await downloadHandle.TryDownloadString(url, true, "");
|
var templateContent = await downloadHandle.TryDownloadString(url);
|
||||||
if (templateContent.IsNullOrEmpty())
|
if (templateContent.IsNullOrEmpty())
|
||||||
{
|
{
|
||||||
return currentItem;
|
return currentItem;
|
||||||
@@ -2780,12 +2902,12 @@ public static class ConfigHandler
|
|||||||
|
|
||||||
if (!template.NormalDNS.IsNullOrEmpty())
|
if (!template.NormalDNS.IsNullOrEmpty())
|
||||||
{
|
{
|
||||||
template.NormalDNS = await downloadHandle.TryDownloadString(template.NormalDNS, true, "");
|
template.NormalDNS = await downloadHandle.TryDownloadString(template.NormalDNS);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!template.TunDNS.IsNullOrEmpty())
|
if (!template.TunDNS.IsNullOrEmpty())
|
||||||
{
|
{
|
||||||
template.TunDNS = await downloadHandle.TryDownloadString(template.TunDNS, true, "");
|
template.TunDNS = await downloadHandle.TryDownloadString(template.TunDNS);
|
||||||
}
|
}
|
||||||
|
|
||||||
template.Id = currentItem.Id;
|
template.Id = currentItem.Id;
|
||||||
@@ -2815,20 +2937,16 @@ public static class ConfigHandler
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async Task<SimpleDNSItem> GetExternalSimpleDNSItem(string url)
|
public static async Task<SimpleDNSItem?> GetExternalSimpleDNSItem(string url)
|
||||||
{
|
{
|
||||||
var downloadHandle = new DownloadService();
|
using var downloadHandle = await DownloadService.CreateDownloadServiceWithCheck();
|
||||||
var templateContent = await downloadHandle.TryDownloadString(url, true, "");
|
var templateContent = await downloadHandle.TryDownloadString(url);
|
||||||
if (templateContent.IsNullOrEmpty())
|
if (templateContent.IsNullOrEmpty())
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var template = JsonUtils.Deserialize<SimpleDNSItem>(templateContent);
|
var template = JsonUtils.Deserialize<SimpleDNSItem>(templateContent);
|
||||||
if (template == null)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return template;
|
return template;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,14 +5,14 @@ public static class ConnectionHandler
|
|||||||
private static readonly string _tag = "ConnectionHandler";
|
private static readonly string _tag = "ConnectionHandler";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Runs ping and IP checks and returns a formatted result string.
|
/// Runs ping and IP checks.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static async Task<string> RunAvailabilityCheck()
|
public static async Task<AvailabilityCheckResult> RunAvailabilityCheck()
|
||||||
{
|
{
|
||||||
var time = await GetRealPingTimeInfo();
|
var time = await GetRealPingTimeInfo();
|
||||||
var ip = time > 0 ? await GetIPInfo() : Global.None;
|
var ip = time > 0 ? await GetIPInfo() : Global.None;
|
||||||
|
|
||||||
return string.Format(ResUI.TestMeOutput, time, ip);
|
return new AvailabilityCheckResult(time, ip);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -20,9 +20,12 @@ public static class ConnectionHandler
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private static async Task<string?> GetIPInfo()
|
private static async Task<string?> GetIPInfo()
|
||||||
{
|
{
|
||||||
var webProxy = await GetWebProxy();
|
using var downloadHandle = new DownloadService
|
||||||
|
{
|
||||||
var ipInfo = await GetIPInfo(webProxy);
|
Handler = HttpClientHelper.CreateSocketsHttpHandler(true, new()),
|
||||||
|
DisposeHandler = true,
|
||||||
|
};
|
||||||
|
var ipInfo = await GetIPInfo(downloadHandle);
|
||||||
return ipInfo?.ToString() ?? Global.None;
|
return ipInfo?.ToString() ?? Global.None;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,11 +37,9 @@ public static class ConnectionHandler
|
|||||||
var responseTime = -1;
|
var responseTime = -1;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var webProxy = await GetWebProxy();
|
|
||||||
|
|
||||||
for (var i = 0; i < 2; i++)
|
for (var i = 0; i < 2; i++)
|
||||||
{
|
{
|
||||||
responseTime = await GetRealPingTime(webProxy);
|
responseTime = await GetRealPingTime(true);
|
||||||
if (responseTime > 0)
|
if (responseTime > 0)
|
||||||
{
|
{
|
||||||
break;
|
break;
|
||||||
@@ -54,54 +55,62 @@ public static class ConnectionHandler
|
|||||||
return responseTime;
|
return responseTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Creates local SOCKS proxy instance.
|
|
||||||
/// </summary>
|
|
||||||
private static async Task<WebProxy?> GetWebProxy()
|
|
||||||
{
|
|
||||||
var port = AppManager.Instance.GetLocalPort(EInboundProtocol.socks);
|
|
||||||
return new WebProxy($"socks5://{Global.Loopback}:{port}");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Measures response time by sending HTTP requests through proxy.
|
/// Measures response time by sending HTTP requests through proxy.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static async Task<int> GetRealPingTime(IWebProxy? webProxy, int downloadTimeout = 9)
|
public static async Task<int> GetRealPingTime(bool useProxy, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var responseTime = -1;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var client = HttpClientHelper.Instance.GetHttpClient(useProxy);
|
||||||
|
responseTime = await GetRealPingTime(client, cancellationToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
return responseTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<int> GetRealPingTime(HttpClient client, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var url = AppManager.Instance.Config.SpeedTestItem.SpeedPingTestUrl;
|
var url = AppManager.Instance.Config.SpeedTestItem.SpeedPingTestUrl;
|
||||||
var responseTime = -1;
|
var responseTime = -1;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var cts = new CancellationTokenSource();
|
using var timeoutCts = new CancellationTokenSource();
|
||||||
cts.CancelAfter(TimeSpan.FromSeconds(downloadTimeout));
|
timeoutCts.CancelAfter(Global.LocalFetch);
|
||||||
using var client = new HttpClient(new SocketsHttpHandler()
|
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token);
|
||||||
{
|
var linkedToken = linkedCts.Token;
|
||||||
Proxy = webProxy,
|
|
||||||
UseProxy = webProxy != null,
|
|
||||||
ConnectTimeout = TimeSpan.FromSeconds(3)
|
|
||||||
});
|
|
||||||
|
|
||||||
List<int> oneTime = [];
|
List<int> oneTime = [];
|
||||||
for (var i = 0; i < 2; i++)
|
for (var i = 0; i < 2; i++)
|
||||||
{
|
{
|
||||||
var timer = Stopwatch.StartNew();
|
var timer = Stopwatch.StartNew();
|
||||||
await client.GetAsync(url, cts.Token).ConfigureAwait(false);
|
await client.GetAsync(url, linkedToken).ConfigureAwait(false);
|
||||||
timer.Stop();
|
timer.Stop();
|
||||||
oneTime.Add((int)timer.Elapsed.TotalMilliseconds);
|
oneTime.Add((int)timer.Elapsed.TotalMilliseconds);
|
||||||
await Task.Delay(100, cts.Token);
|
await Task.Delay(100, linkedToken);
|
||||||
}
|
}
|
||||||
responseTime = oneTime.Where(x => x > 0).OrderBy(x => x).FirstOrDefault();
|
responseTime = oneTime.Where(x => x > 0).OrderBy(x => x).FirstOrDefault();
|
||||||
}
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
|
// Ignore
|
||||||
}
|
}
|
||||||
return responseTime;
|
return responseTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
public static async Task<IpInfoResult?> GetIPInfo(DownloadService downloadHandle, CancellationToken cancellationToken = default)
|
||||||
/// Gets IP and country information through specified proxy.
|
|
||||||
/// </summary>
|
|
||||||
public static async Task<IpInfoResult?> GetIPInfo(IWebProxy? webProxy)
|
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -111,8 +120,7 @@ public static class ConnectionHandler
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var downloadHandle = new DownloadService();
|
var result = await downloadHandle.TryDownloadString(url, cancellationToken);
|
||||||
var result = await downloadHandle.TryDownloadString(url, webProxy, "");
|
|
||||||
if (result == null)
|
if (result == null)
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
@@ -129,6 +137,10 @@ public static class ConnectionHandler
|
|||||||
|
|
||||||
return new IpInfoResult(country, ip);
|
return new IpInfoResult(country, ip);
|
||||||
}
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -17,7 +17,10 @@ public class AnytlsFmt : BaseFmt
|
|||||||
ConfigType = EConfigType.Anytls,
|
ConfigType = EConfigType.Anytls,
|
||||||
Remarks = parsedUrl.GetComponents(UriComponents.Fragment, UriFormat.Unescaped),
|
Remarks = parsedUrl.GetComponents(UriComponents.Fragment, UriFormat.Unescaped),
|
||||||
Address = parsedUrl.IdnHost,
|
Address = parsedUrl.IdnHost,
|
||||||
Port = parsedUrl.Port,
|
// The URI scheme makes the port optional and defaults it to 443
|
||||||
|
// (https://github.com/anytls/anytls-go/blob/main/docs/uri_scheme.md). Uri.Port answers
|
||||||
|
// -1 for an unregistered scheme carrying no port; an explicit ":0" stays 0.
|
||||||
|
Port = parsedUrl.Port == -1 ? 443 : parsedUrl.Port,
|
||||||
};
|
};
|
||||||
var rawUserInfo = Utils.UrlDecode(parsedUrl.UserInfo);
|
var rawUserInfo = Utils.UrlDecode(parsedUrl.UserInfo);
|
||||||
item.Password = rawUserInfo;
|
item.Password = rawUserInfo;
|
||||||
|
|||||||
@@ -80,12 +80,7 @@ public class BaseFmt
|
|||||||
{
|
{
|
||||||
var node = JsonUtils.ParseJson(item.Finalmask);
|
var node = JsonUtils.ParseJson(item.Finalmask);
|
||||||
var finalmask = node != null
|
var finalmask = node != null
|
||||||
? JsonUtils.Serialize(node, new JsonSerializerOptions
|
? JsonUtils.Serialize(node, indented: false, nullValue: true)
|
||||||
{
|
|
||||||
WriteIndented = false,
|
|
||||||
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
|
|
||||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
|
||||||
})
|
|
||||||
: item.Finalmask;
|
: item.Finalmask;
|
||||||
dicQuery.Add("fm", Utils.UrlEncode(finalmask));
|
dicQuery.Add("fm", Utils.UrlEncode(finalmask));
|
||||||
}
|
}
|
||||||
@@ -154,12 +149,7 @@ public class BaseFmt
|
|||||||
{
|
{
|
||||||
var node = JsonUtils.ParseJson(transport.XhttpExtra);
|
var node = JsonUtils.ParseJson(transport.XhttpExtra);
|
||||||
var extra = node != null
|
var extra = node != null
|
||||||
? JsonUtils.Serialize(node, new JsonSerializerOptions
|
? JsonUtils.Serialize(node, indented: false, nullValue: true)
|
||||||
{
|
|
||||||
WriteIndented = false,
|
|
||||||
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
|
|
||||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
|
||||||
})
|
|
||||||
: transport.XhttpExtra;
|
: transport.XhttpExtra;
|
||||||
dicQuery.Add("extra", UrlEncodeSafe(extra));
|
dicQuery.Add("extra", UrlEncodeSafe(extra));
|
||||||
}
|
}
|
||||||
@@ -215,12 +205,7 @@ public class BaseFmt
|
|||||||
{
|
{
|
||||||
var node = JsonUtils.ParseJson(finalmaskDecoded);
|
var node = JsonUtils.ParseJson(finalmaskDecoded);
|
||||||
item.Finalmask = node != null
|
item.Finalmask = node != null
|
||||||
? JsonUtils.Serialize(node, new JsonSerializerOptions
|
? JsonUtils.Serialize(node, indented: true, nullValue: true)
|
||||||
{
|
|
||||||
WriteIndented = true,
|
|
||||||
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
|
|
||||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
|
||||||
})
|
|
||||||
: finalmaskDecoded;
|
: finalmaskDecoded;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -278,12 +263,7 @@ public class BaseFmt
|
|||||||
var node = JsonUtils.ParseJson(xhttpExtra);
|
var node = JsonUtils.ParseJson(xhttpExtra);
|
||||||
if (node != null)
|
if (node != null)
|
||||||
{
|
{
|
||||||
xhttpExtra = JsonUtils.Serialize(node, new JsonSerializerOptions
|
xhttpExtra = JsonUtils.Serialize(node, indented: true, nullValue: true);
|
||||||
{
|
|
||||||
WriteIndented = true,
|
|
||||||
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
|
|
||||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -179,12 +179,7 @@ public class InnerFmt : BaseFmt
|
|||||||
if (jsonObj.TryGetPropertyValue("CustomOutboundObj", out var customOutboundNode)
|
if (jsonObj.TryGetPropertyValue("CustomOutboundObj", out var customOutboundNode)
|
||||||
&& customOutboundNode is JsonObject customOutboundObj)
|
&& customOutboundNode is JsonObject customOutboundObj)
|
||||||
{
|
{
|
||||||
var customOutboundContent = JsonUtils.Serialize(customOutboundObj, new JsonSerializerOptions
|
var customOutboundContent = JsonUtils.Serialize(customOutboundObj, indented: true, nullValue: true);
|
||||||
{
|
|
||||||
WriteIndented = true,
|
|
||||||
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
|
|
||||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
|
||||||
});
|
|
||||||
customOutboundFilePath = WriteAllText(customOutboundContent);
|
customOutboundFilePath = WriteAllText(customOutboundContent);
|
||||||
jsonObj.Remove("CustomOutboundObj");
|
jsonObj.Remove("CustomOutboundObj");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,10 @@ public class NaiveFmt : BaseFmt
|
|||||||
ConfigType = EConfigType.Naive,
|
ConfigType = EConfigType.Naive,
|
||||||
Remarks = parsedUrl.GetComponents(UriComponents.Fragment, UriFormat.Unescaped),
|
Remarks = parsedUrl.GetComponents(UriComponents.Fragment, UriFormat.Unescaped),
|
||||||
Address = parsedUrl.IdnHost,
|
Address = parsedUrl.IdnHost,
|
||||||
Port = parsedUrl.Port,
|
// naive+https:// and naive+quic:// links leave the port out when it is 443
|
||||||
|
// (https://gist.github.com/DuckSoft/ca03913b0a26fc77a1da4d01cc6ab2f1). Uri.Port answers
|
||||||
|
// -1 for an unregistered scheme carrying no port; an explicit ":0" stays 0.
|
||||||
|
Port = parsedUrl.Port == -1 ? 443 : parsedUrl.Port,
|
||||||
};
|
};
|
||||||
var protocolExtra = item.GetProtocolExtra();
|
var protocolExtra = item.GetProtocolExtra();
|
||||||
if (parsedUrl.Scheme.Contains("quic"))
|
if (parsedUrl.Scheme.Contains("quic"))
|
||||||
@@ -27,16 +30,17 @@ public class NaiveFmt : BaseFmt
|
|||||||
NaiveQuic = true,
|
NaiveQuic = true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
var rawUserInfo = Utils.UrlDecode(parsedUrl.UserInfo);
|
// Split before decoding: ToUri percent-encodes a ':' inside the username or password.
|
||||||
|
var rawUserInfo = parsedUrl.UserInfo;
|
||||||
if (rawUserInfo.Contains(':'))
|
if (rawUserInfo.Contains(':'))
|
||||||
{
|
{
|
||||||
var split = rawUserInfo.Split(':', 2);
|
var split = rawUserInfo.Split(':', 2);
|
||||||
item.Username = split[0];
|
item.Username = Utils.UrlDecode(split[0]);
|
||||||
item.Password = split[1];
|
item.Password = Utils.UrlDecode(split[1]);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
item.Password = rawUserInfo;
|
item.Password = Utils.UrlDecode(rawUserInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
var query = Utils.ParseQueryString(parsedUrl.Query);
|
var query = Utils.ParseQueryString(parsedUrl.Query);
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ public class ShadowsocksFmt : BaseFmt
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
item.SetProtocolExtra(item.GetProtocolExtra() with { SsMethod = userInfoParts.First() });
|
item.SetProtocolExtra(item.GetProtocolExtra() with { SsMethod = userInfoParts.First() });
|
||||||
item.Password = Utils.UrlDecode(userInfoParts.Last());
|
item.Password = userInfoParts.Last();
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -86,7 +86,10 @@ public class SocksFmt : BaseFmt
|
|||||||
private static ProfileItem? ResolveSocksNew(string result)
|
private static ProfileItem? ResolveSocksNew(string result)
|
||||||
{
|
{
|
||||||
var parsedUrl = Utils.TryUri(result);
|
var parsedUrl = Utils.TryUri(result);
|
||||||
if (parsedUrl == null)
|
// A legacy socks://base64(user:pass@host:port) link has no port of its own, but System.Uri
|
||||||
|
// accepts many of them, reading the base64 as the host (and whatever follows a '/' as the
|
||||||
|
// path). Leave those to ResolveSocks.
|
||||||
|
if (parsedUrl == null || parsedUrl.Port == -1)
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,19 @@ public class WireguardFmt : BaseFmt
|
|||||||
|
|
||||||
var query = Utils.ParseQueryString(url.Query);
|
var query = Utils.ParseQueryString(url.Query);
|
||||||
|
|
||||||
|
var finalmaskDecoded = GetQueryDecoded(query, "fm");
|
||||||
|
if (finalmaskDecoded.IsNotEmpty())
|
||||||
|
{
|
||||||
|
var node = JsonUtils.ParseJson(finalmaskDecoded);
|
||||||
|
item.Finalmask = node != null
|
||||||
|
? JsonUtils.Serialize(node, indented: true, nullValue: true)
|
||||||
|
: finalmaskDecoded;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
item.Finalmask = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
item.SetProtocolExtra(item.GetProtocolExtra() with
|
item.SetProtocolExtra(item.GetProtocolExtra() with
|
||||||
{
|
{
|
||||||
WgPublicKey = GetQueryDecoded(query, "publickey"),
|
WgPublicKey = GetQueryDecoded(query, "publickey"),
|
||||||
@@ -76,6 +89,14 @@ public class WireguardFmt : BaseFmt
|
|||||||
{
|
{
|
||||||
dicQuery.Add("dns", Utils.UrlEncode(protoExtra.WgDns));
|
dicQuery.Add("dns", Utils.UrlEncode(protoExtra.WgDns));
|
||||||
}
|
}
|
||||||
|
if (item.Finalmask.IsNotEmpty())
|
||||||
|
{
|
||||||
|
var node = JsonUtils.ParseJson(item.Finalmask);
|
||||||
|
var finalmask = node != null
|
||||||
|
? JsonUtils.Serialize(node, indented: false, nullValue: true)
|
||||||
|
: item.Finalmask;
|
||||||
|
dicQuery.Add("fm", Utils.UrlEncode(finalmask));
|
||||||
|
}
|
||||||
return ToUri(EConfigType.WireGuard, item.Address, item.Port, item.Password, dicQuery, remark);
|
return ToUri(EConfigType.WireGuard, item.Address, item.Port, item.Password, dicQuery, remark);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
namespace ServiceLib.Handler;
|
namespace ServiceLib.Handler;
|
||||||
|
|
||||||
public static class SubscriptionHandler
|
public class SubscriptionHandler(
|
||||||
|
Config config,
|
||||||
|
bool blProxy,
|
||||||
|
Func<bool, string, Task> updateFunc)
|
||||||
{
|
{
|
||||||
public static async Task UpdateProcess(Config config, string subId, bool blProxy, Func<bool, string, Task> updateFunc)
|
private readonly Config _config = config;
|
||||||
|
private readonly bool _blProxy = blProxy;
|
||||||
|
|
||||||
|
public async Task UpdateProcess(string subId)
|
||||||
{
|
{
|
||||||
await updateFunc?.Invoke(false, ResUI.MsgUpdateSubscriptionStart);
|
await updateFunc?.Invoke(false, ResUI.MsgUpdateSubscriptionStart);
|
||||||
var subItem = await AppManager.Instance.SubItems();
|
var subItem = await AppManager.Instance.SubItems();
|
||||||
@@ -24,21 +30,20 @@ public static class SubscriptionHandler
|
|||||||
}
|
}
|
||||||
|
|
||||||
var hashCode = $"{item.Remarks}->";
|
var hashCode = $"{item.Remarks}->";
|
||||||
if (item.Enabled == false)
|
if (!item.Enabled)
|
||||||
{
|
{
|
||||||
await updateFunc?.Invoke(false, $"{hashCode}{ResUI.MsgSkipSubscriptionUpdate}");
|
await updateFunc?.Invoke(false, $"{hashCode}{ResUI.MsgSkipSubscriptionUpdate}");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create download handler
|
using var processor = new SubscriptionHandlerProcessor(this, item);
|
||||||
var downloadHandle = CreateDownloadHandler(hashCode, updateFunc);
|
|
||||||
await updateFunc?.Invoke(false, $"{hashCode}{ResUI.MsgStartGettingSubscriptions}");
|
await updateFunc?.Invoke(false, $"{hashCode}{ResUI.MsgStartGettingSubscriptions}");
|
||||||
|
|
||||||
// Get all subscription content (main subscription + additional subscriptions)
|
// Get all subscription content (main subscription + additional subscriptions)
|
||||||
var result = await DownloadAllSubscriptions(config, item, blProxy, downloadHandle);
|
var result = await processor.DownloadAllSubscriptions();
|
||||||
|
|
||||||
// Process download result
|
// Process download result
|
||||||
if (await ProcessDownloadResult(config, item.Id, result, hashCode, updateFunc))
|
if (await ProcessDownloadResult(item.Id, result, hashCode))
|
||||||
{
|
{
|
||||||
successCount++;
|
successCount++;
|
||||||
}
|
}
|
||||||
@@ -57,7 +62,7 @@ public static class SubscriptionHandler
|
|||||||
await updateFunc?.Invoke(successCount > 0, $"{ResUI.MsgUpdateSubscriptionEnd}");
|
await updateFunc?.Invoke(successCount > 0, $"{ResUI.MsgUpdateSubscriptionEnd}");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsValidSubscription(SubItem item, string subId)
|
private bool IsValidSubscription(SubItem item, string subId)
|
||||||
{
|
{
|
||||||
var id = item.Id.TrimEx();
|
var id = item.Id.TrimEx();
|
||||||
var url = item.Url.TrimEx();
|
var url = item.Url.TrimEx();
|
||||||
@@ -80,112 +85,7 @@ public static class SubscriptionHandler
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static DownloadService CreateDownloadHandler(string hashCode, Func<bool, string, Task> updateFunc)
|
private async Task<bool> ProcessDownloadResult(string id, string result, string hashCode)
|
||||||
{
|
|
||||||
var downloadHandle = new DownloadService();
|
|
||||||
downloadHandle.Error += (sender2, args) =>
|
|
||||||
{
|
|
||||||
updateFunc?.Invoke(false, $"{hashCode}{args.GetException().Message}");
|
|
||||||
};
|
|
||||||
return downloadHandle;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<string> DownloadSubscriptionContent(DownloadService downloadHandle, string url, bool blProxy, string userAgent)
|
|
||||||
{
|
|
||||||
var result = await downloadHandle.TryDownloadString(url, blProxy, userAgent);
|
|
||||||
|
|
||||||
// If download with proxy fails, try direct connection
|
|
||||||
if (blProxy && result.IsNullOrEmpty())
|
|
||||||
{
|
|
||||||
result = await downloadHandle.TryDownloadString(url, false, userAgent);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result ?? string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<string> DownloadAllSubscriptions(Config config, SubItem item, bool blProxy, DownloadService downloadHandle)
|
|
||||||
{
|
|
||||||
// Download main subscription content
|
|
||||||
var result = await DownloadMainSubscription(config, item, blProxy, downloadHandle);
|
|
||||||
|
|
||||||
// Process additional subscription links (if any)
|
|
||||||
if (item.ConvertTarget.IsNullOrEmpty() && item.MoreUrl.TrimEx().IsNotEmpty())
|
|
||||||
{
|
|
||||||
result = await DownloadAdditionalSubscriptions(item, result, blProxy, downloadHandle);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<string> DownloadMainSubscription(Config config, SubItem item, bool blProxy, DownloadService downloadHandle)
|
|
||||||
{
|
|
||||||
// Prepare subscription URL and download directly
|
|
||||||
var url = Utils.GetPunycode(item.Url.TrimEx());
|
|
||||||
|
|
||||||
// If conversion is needed
|
|
||||||
if (item.ConvertTarget.IsNotEmpty())
|
|
||||||
{
|
|
||||||
var subConvertUrl = config.ConstItem.SubConvertUrl.IsNullOrEmpty()
|
|
||||||
? Global.SubConvertUrls.FirstOrDefault()
|
|
||||||
: config.ConstItem.SubConvertUrl;
|
|
||||||
|
|
||||||
url = string.Format(subConvertUrl!, Utils.UrlEncode(url));
|
|
||||||
|
|
||||||
if (!url.Contains("target="))
|
|
||||||
{
|
|
||||||
url += $"&target={item.ConvertTarget}";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!url.Contains("config="))
|
|
||||||
{
|
|
||||||
url += $"&config={Global.SubConvertConfig.FirstOrDefault()}";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Download and return result directly
|
|
||||||
return await DownloadSubscriptionContent(downloadHandle, url, blProxy, item.UserAgent);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<string> DownloadAdditionalSubscriptions(SubItem item, string mainResult, bool blProxy, DownloadService downloadHandle)
|
|
||||||
{
|
|
||||||
var result = mainResult;
|
|
||||||
|
|
||||||
// If main subscription result is Base64 encoded, decode it first
|
|
||||||
if (result.IsNotEmpty() && Utils.IsBase64String(result))
|
|
||||||
{
|
|
||||||
result = Utils.Base64Decode(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process additional URL list
|
|
||||||
var lstUrl = item.MoreUrl.TrimEx().Split(",") ?? [];
|
|
||||||
foreach (var it in lstUrl)
|
|
||||||
{
|
|
||||||
var url2 = Utils.GetPunycode(it);
|
|
||||||
if (url2.IsNullOrEmpty())
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var additionalResult = await DownloadSubscriptionContent(downloadHandle, url2, blProxy, item.UserAgent);
|
|
||||||
|
|
||||||
if (additionalResult.IsNotEmpty())
|
|
||||||
{
|
|
||||||
// Process additional subscription results, add to main result
|
|
||||||
if (Utils.IsBase64String(additionalResult))
|
|
||||||
{
|
|
||||||
result += Environment.NewLine + Utils.Base64Decode(additionalResult);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
result += Environment.NewLine + additionalResult;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<bool> ProcessDownloadResult(Config config, string id, string result, string hashCode, Func<bool, string, Task> updateFunc)
|
|
||||||
{
|
{
|
||||||
if (result.IsNullOrEmpty())
|
if (result.IsNullOrEmpty())
|
||||||
{
|
{
|
||||||
@@ -204,7 +104,7 @@ public static class SubscriptionHandler
|
|||||||
await updateFunc?.Invoke(false, $"{hashCode}{ResUI.MsgStartParsingSubscription}");
|
await updateFunc?.Invoke(false, $"{hashCode}{ResUI.MsgStartParsingSubscription}");
|
||||||
|
|
||||||
// Add servers to configuration
|
// Add servers to configuration
|
||||||
var ret = await ConfigHandler.AddBatchServers(config, result, id, true);
|
var ret = await ConfigHandler.AddBatchServers(_config, result, id, true);
|
||||||
if (ret <= 0)
|
if (ret <= 0)
|
||||||
{
|
{
|
||||||
Logging.SaveLog("FailedImportSubscription");
|
Logging.SaveLog("FailedImportSubscription");
|
||||||
@@ -218,4 +118,170 @@ public static class SubscriptionHandler
|
|||||||
|
|
||||||
return ret > 0;
|
return ret > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private class SubscriptionHandlerProcessor : IDisposable
|
||||||
|
{
|
||||||
|
private readonly DownloadService[] _downloadServices;
|
||||||
|
private readonly SubscriptionHandler _handler;
|
||||||
|
private readonly SubItem _item;
|
||||||
|
|
||||||
|
public SubscriptionHandlerProcessor(SubscriptionHandler handler, SubItem item)
|
||||||
|
{
|
||||||
|
_handler = handler;
|
||||||
|
_item = item;
|
||||||
|
_downloadServices = CreateDownloadHandler();
|
||||||
|
}
|
||||||
|
|
||||||
|
private DownloadService[] CreateDownloadHandler()
|
||||||
|
{
|
||||||
|
if (!HttpRequestHeadersHelper.TryParse(_item.RequestHeaders, out var requestHeaders))
|
||||||
|
{
|
||||||
|
throw new FormatException(ResUI.SubRequestHeadersInvalid);
|
||||||
|
}
|
||||||
|
|
||||||
|
var userAgent = _item.UserAgent.IsNullOrEmpty() ? Utils.GetVersion(false) : _item.UserAgent;
|
||||||
|
//var proxyDownloadService = new DownloadService
|
||||||
|
//{
|
||||||
|
// GetHandlerFunc = () =>
|
||||||
|
// HttpRequestHeadersHelper.CreateHandler(HttpClientHelper.Instance.ProxyHandler, userAgent, "*/*",
|
||||||
|
// requestHeaders),
|
||||||
|
//};
|
||||||
|
//var directDownloadService = new DownloadService
|
||||||
|
//{
|
||||||
|
// GetHandlerFunc = () =>
|
||||||
|
// HttpRequestHeadersHelper.CreateHandler(HttpClientHelper.Instance.DirectHandler, userAgent, "*/*",
|
||||||
|
// requestHeaders),
|
||||||
|
//};
|
||||||
|
//return _handler._blProxy
|
||||||
|
// ? [
|
||||||
|
// proxyDownloadService,
|
||||||
|
// directDownloadService,
|
||||||
|
// ]
|
||||||
|
// : [
|
||||||
|
// directDownloadService,
|
||||||
|
// proxyDownloadService,
|
||||||
|
// ];
|
||||||
|
var handler = HttpClientHelper.Instance.CreateFallbackHandler(_handler._blProxy, new());
|
||||||
|
var headersHandler = HttpRequestHeadersHelper.CreateHandler(handler, userAgent, "*/*", requestHeaders);
|
||||||
|
return
|
||||||
|
[
|
||||||
|
new DownloadService
|
||||||
|
{
|
||||||
|
Handler = headersHandler,
|
||||||
|
DisposeHandler = true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string> DownloadSubscriptionContent(string url)
|
||||||
|
{
|
||||||
|
//var result = await downloadServices[0].TryDownloadString(url);
|
||||||
|
|
||||||
|
//// try other ways
|
||||||
|
//if (result.IsNullOrEmpty())
|
||||||
|
//{
|
||||||
|
// result = await downloadServices[1].TryDownloadString(url);
|
||||||
|
//}
|
||||||
|
foreach (var downloadService in _downloadServices)
|
||||||
|
{
|
||||||
|
var result = await downloadService.TryDownloadString(url);
|
||||||
|
if (!result.IsNullOrEmpty())
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> DownloadAllSubscriptions()
|
||||||
|
{
|
||||||
|
// Download main subscription content
|
||||||
|
var result = await DownloadMainSubscription();
|
||||||
|
|
||||||
|
// Process additional subscription links (if any)
|
||||||
|
if (_item.ConvertTarget.IsNullOrEmpty() && _item.MoreUrl.TrimEx().IsNotEmpty())
|
||||||
|
{
|
||||||
|
result = await DownloadAdditionalSubscriptions(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string> DownloadMainSubscription()
|
||||||
|
{
|
||||||
|
// Prepare subscription URL and download directly
|
||||||
|
var url = Utils.GetPunycode(_item.Url.TrimEx());
|
||||||
|
|
||||||
|
// If conversion is needed
|
||||||
|
if (_item.ConvertTarget.IsNotEmpty())
|
||||||
|
{
|
||||||
|
var subConvertUrl = _handler._config.ConstItem.SubConvertUrl.IsNullOrEmpty()
|
||||||
|
? Global.SubConvertUrls.FirstOrDefault()
|
||||||
|
: _handler._config.ConstItem.SubConvertUrl;
|
||||||
|
|
||||||
|
url = string.Format(subConvertUrl!, Utils.UrlEncode(url));
|
||||||
|
|
||||||
|
if (!url.Contains("target="))
|
||||||
|
{
|
||||||
|
url += $"&target={_item.ConvertTarget}";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!url.Contains("config="))
|
||||||
|
{
|
||||||
|
url += $"&config={Global.SubConvertConfig.FirstOrDefault()}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download and return result directly
|
||||||
|
return await DownloadSubscriptionContent(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string> DownloadAdditionalSubscriptions(string mainResult)
|
||||||
|
{
|
||||||
|
var result = mainResult;
|
||||||
|
|
||||||
|
// If main subscription result is Base64 encoded, decode it first
|
||||||
|
if (result.IsNotEmpty() && Utils.IsBase64String(result))
|
||||||
|
{
|
||||||
|
result = Utils.Base64Decode(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process additional URL list
|
||||||
|
var lstUrl = _item.MoreUrl.TrimEx().Split(",") ?? [];
|
||||||
|
foreach (var it in lstUrl)
|
||||||
|
{
|
||||||
|
var url2 = Utils.GetPunycode(it);
|
||||||
|
if (url2.IsNullOrEmpty())
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var additionalResult = await DownloadSubscriptionContent(url2);
|
||||||
|
|
||||||
|
if (additionalResult.IsNotEmpty())
|
||||||
|
{
|
||||||
|
// Process additional subscription results, add to main result
|
||||||
|
if (Utils.IsBase64String(additionalResult))
|
||||||
|
{
|
||||||
|
result += Environment.NewLine + Utils.Base64Decode(additionalResult);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
result += Environment.NewLine + additionalResult;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
foreach (var downloadService in _downloadServices)
|
||||||
|
{
|
||||||
|
downloadService.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ public static class ProxySettingWindows
|
|||||||
list.dwOptionCount = options.Length;
|
list.dwOptionCount = options.Length;
|
||||||
list.dwOptionError = 0;
|
list.dwOptionError = 0;
|
||||||
|
|
||||||
var optSize = Marshal.SizeOf(typeof(InternetConnectionOption));
|
var optSize = Marshal.SizeOf<InternetConnectionOption>();
|
||||||
// make a pointer out of all that ...
|
// make a pointer out of all that ...
|
||||||
var optionsPtr = Marshal.AllocCoTaskMem(optSize * options.Length); // !! remember to deallocate memory 4
|
var optionsPtr = Marshal.AllocCoTaskMem(optSize * options.Length); // !! remember to deallocate memory 4
|
||||||
// copy the array over into that spot in memory ...
|
// copy the array over into that spot in memory ...
|
||||||
@@ -207,17 +207,17 @@ public static class ProxySettingWindows
|
|||||||
var entries = 0;
|
var entries = 0;
|
||||||
// attempt to query with 1 entry buffer
|
// attempt to query with 1 entry buffer
|
||||||
var rasEntryNames = new RASENTRYNAME[1];
|
var rasEntryNames = new RASENTRYNAME[1];
|
||||||
var bufferSize = Marshal.SizeOf(typeof(RASENTRYNAME));
|
var bufferSize = Marshal.SizeOf<RASENTRYNAME>();
|
||||||
rasEntryNames[0].dwSize = Marshal.SizeOf(typeof(RASENTRYNAME));
|
rasEntryNames[0].dwSize = Marshal.SizeOf<RASENTRYNAME>();
|
||||||
|
|
||||||
var result = NativeMethods.RasEnumEntries(null, null, rasEntryNames, ref bufferSize, ref entries);
|
var result = NativeMethods.RasEnumEntries(null, null, rasEntryNames, ref bufferSize, ref entries);
|
||||||
// increase buffer if the buffer is not large enough
|
// increase buffer if the buffer is not large enough
|
||||||
if (result == (uint)ErrorCode.ERROR_BUFFER_TOO_SMALL)
|
if (result == (uint)ErrorCode.ERROR_BUFFER_TOO_SMALL)
|
||||||
{
|
{
|
||||||
rasEntryNames = new RASENTRYNAME[bufferSize / Marshal.SizeOf(typeof(RASENTRYNAME))];
|
rasEntryNames = new RASENTRYNAME[bufferSize / Marshal.SizeOf<RASENTRYNAME>()];
|
||||||
for (var i = 0; i < rasEntryNames.Length; i++)
|
for (var i = 0; i < rasEntryNames.Length; i++)
|
||||||
{
|
{
|
||||||
rasEntryNames[i].dwSize = Marshal.SizeOf(typeof(RASENTRYNAME));
|
rasEntryNames[i].dwSize = Marshal.SizeOf<RASENTRYNAME>();
|
||||||
}
|
}
|
||||||
|
|
||||||
result = NativeMethods.RasEnumEntries(null, null, rasEntryNames, ref bufferSize, ref entries);
|
result = NativeMethods.RasEnumEntries(null, null, rasEntryNames, ref bufferSize, ref entries);
|
||||||
@@ -258,7 +258,7 @@ public static class ProxySettingWindows
|
|||||||
|
|
||||||
static InternetConnectionOption()
|
static InternetConnectionOption()
|
||||||
{
|
{
|
||||||
Size = Marshal.SizeOf(typeof(InternetConnectionOption));
|
Size = Marshal.SizeOf<InternetConnectionOption>();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nested Types
|
// Nested Types
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ namespace ServiceLib.Handler.SysProxy;
|
|||||||
public static class SysProxyHandler
|
public static class SysProxyHandler
|
||||||
{
|
{
|
||||||
private static readonly string _tag = "SysProxyHandler";
|
private static readonly string _tag = "SysProxyHandler";
|
||||||
|
private static readonly Lazy<PacManager> _pacManager = new(() => new PacManager());
|
||||||
|
|
||||||
public static async Task<bool> UpdateSysProxy(Config config, bool forceDisable)
|
public static async Task<bool> UpdateSysProxy(Config config, bool forceDisable)
|
||||||
{
|
{
|
||||||
@@ -56,7 +57,7 @@ public static class SysProxyHandler
|
|||||||
|
|
||||||
if (type != ESysProxyType.Pac && Utils.IsWindows())
|
if (type != ESysProxyType.Pac && Utils.IsWindows())
|
||||||
{
|
{
|
||||||
PacManager.Instance.Stop();
|
_pacManager.Value.Stop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -110,7 +111,7 @@ public static class SysProxyHandler
|
|||||||
private static async Task SetWindowsProxyPac(int port)
|
private static async Task SetWindowsProxyPac(int port)
|
||||||
{
|
{
|
||||||
var portPac = AppManager.Instance.GetLocalPort(EInboundProtocol.pac);
|
var portPac = AppManager.Instance.GetLocalPort(EInboundProtocol.pac);
|
||||||
await PacManager.Instance.StartAsync(port, portPac);
|
await _pacManager.Value.StartAsync(port, portPac);
|
||||||
var strProxy = $"{Global.HttpProtocol}{Global.Loopback}:{portPac}/pac?t={DateTime.Now.Ticks}";
|
var strProxy = $"{Global.HttpProtocol}{Global.Loopback}:{portPac}/pac?t={DateTime.Now.Ticks}";
|
||||||
ProxySettingWindows.SetProxy(strProxy, "", 4);
|
ProxySettingWindows.SetProxy(strProxy, "", 4);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,46 +1,41 @@
|
|||||||
using System.Security.Authentication;
|
|
||||||
using Downloader;
|
using Downloader;
|
||||||
|
using DownloadService = Downloader.DownloadService;
|
||||||
|
|
||||||
namespace ServiceLib.Helper;
|
namespace ServiceLib.Helper;
|
||||||
|
|
||||||
public class DownloaderHelper
|
public class DownloaderHelper : IDisposable
|
||||||
{
|
{
|
||||||
private static readonly Lazy<DownloaderHelper> _instance = new(() => new());
|
public HttpMessageHandler Handler { get; init; } = HttpClientHelper.Instance.DirectHandler;
|
||||||
public static DownloaderHelper Instance => _instance.Value;
|
|
||||||
|
|
||||||
public async Task<string?> DownloadStringAsync(IWebProxy? webProxy, string url, string? userAgent, int timeout)
|
public bool DisposeHandler { get; init; } = false;
|
||||||
|
|
||||||
|
public async Task<string?> DownloadStringAsync(string url,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (url.IsNullOrEmpty())
|
if (url.IsNullOrEmpty())
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var connectTimeout = Math.Clamp(timeout / 5, 2, 5);
|
|
||||||
|
|
||||||
Uri uri = new(url);
|
|
||||||
//Authorization Header
|
|
||||||
var headers = new WebHeaderCollection();
|
var headers = new WebHeaderCollection();
|
||||||
|
var uri = new Uri(url);
|
||||||
if (uri.UserInfo.IsNotEmpty())
|
if (uri.UserInfo.IsNotEmpty())
|
||||||
{
|
{
|
||||||
headers.Add(HttpRequestHeader.Authorization, "Basic " + Utils.Base64Encode(uri.UserInfo));
|
headers.Add(HttpRequestHeader.Authorization, "Basic " + Utils.Base64Encode(uri.UserInfo));
|
||||||
}
|
}
|
||||||
|
|
||||||
var requestConfiguration = new RequestConfiguration()
|
var requestConfiguration = new RequestConfiguration
|
||||||
{
|
{
|
||||||
Headers = headers,
|
Headers = headers,
|
||||||
UserAgent = userAgent,
|
|
||||||
ConnectTimeout = connectTimeout * 1000,
|
|
||||||
Proxy = webProxy
|
|
||||||
};
|
};
|
||||||
var downloadOpt = new DownloadConfiguration()
|
var downloadOpt = new DownloadConfiguration
|
||||||
{
|
{
|
||||||
BlockTimeout = timeout * 1000,
|
|
||||||
MaxTryAgainOnFailure = 2,
|
MaxTryAgainOnFailure = 2,
|
||||||
RequestConfiguration = requestConfiguration,
|
RequestConfiguration = requestConfiguration,
|
||||||
CustomHttpMessageHandlerFactory = () => GetSocketsHttpHandler(requestConfiguration),
|
CustomHttpMessageHandlerFactory = GetHandler,
|
||||||
};
|
};
|
||||||
|
|
||||||
await using var downloader = new Downloader.DownloadService(downloadOpt);
|
await using var downloader = new DownloadService(downloadOpt);
|
||||||
downloader.DownloadFileCompleted += (sender, value) =>
|
downloader.DownloadFileCompleted += (sender, value) =>
|
||||||
{
|
{
|
||||||
if (value.Error != null)
|
if (value.Error != null)
|
||||||
@@ -49,45 +44,37 @@ public class DownloaderHelper
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
using var cts = new CancellationTokenSource();
|
await using var stream = await downloader.DownloadFileTaskAsync(url, cancellationToken);
|
||||||
cts.CancelAfter(TimeSpan.FromSeconds(timeout));
|
|
||||||
|
|
||||||
await using var stream = await downloader.DownloadFileTaskAsync(address: url, cts.Token);
|
|
||||||
using StreamReader reader = new(stream);
|
using StreamReader reader = new(stream);
|
||||||
|
|
||||||
return await reader.ReadToEndAsync(cts.Token);
|
return await reader.ReadToEndAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task DownloadDataAsync4Speed(IWebProxy webProxy, string url, IProgress<string> progress, int timeout)
|
public async Task DownloadDataAsync4Speed(string url, Action<string> onProgress,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (url.IsNullOrEmpty())
|
if (url.IsNullOrEmpty())
|
||||||
{
|
{
|
||||||
throw new ArgumentNullException(nameof(url));
|
throw new ArgumentNullException(nameof(url));
|
||||||
}
|
}
|
||||||
|
|
||||||
var connectTimeout = Math.Clamp(timeout / 5, 2, 5);
|
var downloadOpt = new DownloadConfiguration
|
||||||
var requestConfiguration = new RequestConfiguration()
|
|
||||||
{
|
{
|
||||||
ConnectTimeout = connectTimeout * 1000,
|
|
||||||
Proxy = webProxy
|
|
||||||
};
|
|
||||||
var downloadOpt = new DownloadConfiguration()
|
|
||||||
{
|
|
||||||
BlockTimeout = timeout * 1000,
|
|
||||||
MaxTryAgainOnFailure = 2,
|
MaxTryAgainOnFailure = 2,
|
||||||
RequestConfiguration = requestConfiguration,
|
CustomHttpMessageHandlerFactory = GetHandler,
|
||||||
CustomHttpMessageHandlerFactory = () => GetSocketsHttpHandler(requestConfiguration),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
var lastUpdateTime = DateTime.Now;
|
var lastUpdateTime = DateTime.Now;
|
||||||
var hasValue = false;
|
var hasValue = false;
|
||||||
double maxSpeed = 0;
|
double maxSpeed = 0;
|
||||||
await using var downloader = new Downloader.DownloadService(downloadOpt);
|
await using var downloader = new DownloadService(downloadOpt);
|
||||||
|
|
||||||
downloader.DownloadProgressChanged += (sender, value) =>
|
downloader.DownloadProgressChanged += (sender, value) =>
|
||||||
{
|
{
|
||||||
if (progress != null && value.BytesPerSecondSpeed > 0)
|
if (!(value.BytesPerSecondSpeed > 0))
|
||||||
{
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
hasValue = true;
|
hasValue = true;
|
||||||
if (value.BytesPerSecondSpeed > maxSpeed)
|
if (value.BytesPerSecondSpeed > maxSpeed)
|
||||||
{
|
{
|
||||||
@@ -99,36 +86,31 @@ public class DownloaderHelper
|
|||||||
{
|
{
|
||||||
lastUpdateTime = DateTime.Now;
|
lastUpdateTime = DateTime.Now;
|
||||||
var speed = (maxSpeed / 1000 / 1000).ToString("#0.0");
|
var speed = (maxSpeed / 1000 / 1000).ToString("#0.0");
|
||||||
progress.Report(speed);
|
onProgress.Invoke(speed);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
downloader.DownloadFileCompleted += (sender, value) =>
|
downloader.DownloadFileCompleted += (sender, value) =>
|
||||||
{
|
|
||||||
if (progress != null)
|
|
||||||
{
|
{
|
||||||
if (hasValue && maxSpeed > 0)
|
if (hasValue && maxSpeed > 0)
|
||||||
{
|
{
|
||||||
var finalSpeed = (maxSpeed / 1000 / 1000).ToString("#0.0");
|
var finalSpeed = (maxSpeed / 1000 / 1000).ToString("#0.0");
|
||||||
progress.Report(finalSpeed);
|
onProgress.Invoke(finalSpeed);
|
||||||
}
|
}
|
||||||
else if (value.Error != null)
|
else if (value.Error != null)
|
||||||
{
|
{
|
||||||
progress.Report(value.Error?.Message);
|
onProgress.Invoke(value.Error?.Message);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
progress.Report("0");
|
onProgress.Invoke("0");
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
//progress.Report("......");
|
// progress.Invoke("......");
|
||||||
using var cts = new CancellationTokenSource();
|
await using var stream = await downloader.DownloadFileTaskAsync(url, cancellationToken);
|
||||||
cts.CancelAfter(TimeSpan.FromSeconds(timeout));
|
|
||||||
await using var stream = await downloader.DownloadFileTaskAsync(address: url, cts.Token);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task DownloadFileAsync(IWebProxy? webProxy, FileDownloadRequest request, Action<FileDownloadState> onProgress, TimeSpan connectTimeout, CancellationToken cancellationToken = default)
|
public async Task DownloadFileAsync(FileDownloadRequest request, Action<FileDownloadState> onProgress,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(request);
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
if (request.FilePath.IsNullOrEmpty())
|
if (request.FilePath.IsNullOrEmpty())
|
||||||
@@ -145,24 +127,18 @@ public class DownloaderHelper
|
|||||||
Request = request,
|
Request = request,
|
||||||
};
|
};
|
||||||
|
|
||||||
var requestConfiguration = new RequestConfiguration()
|
var downloadOpt = new DownloadConfiguration
|
||||||
{
|
{
|
||||||
ConnectTimeout = (int)connectTimeout.TotalMilliseconds,
|
ChunkCount = 16,
|
||||||
Proxy = webProxy,
|
|
||||||
};
|
|
||||||
var downloadOpt = new DownloadConfiguration()
|
|
||||||
{
|
|
||||||
ChunkCount = 100,
|
|
||||||
MinimumChunkSize = 8 * 1024 * 1024, // 8 MB
|
|
||||||
MinimumSizeOfChunking = 8 * 1024 * 1024, // 8 MB
|
|
||||||
ParallelDownload = true,
|
ParallelDownload = true,
|
||||||
ParallelCount = 4,
|
ParallelCount = 4,
|
||||||
|
MinimumChunkSize = 2 * 1024 * 1024, // 2 MB
|
||||||
|
MinimumSizeOfChunking = 4 * 1024 * 1024, // 4 MB
|
||||||
|
|
||||||
RequestConfiguration = requestConfiguration,
|
CustomHttpMessageHandlerFactory = GetHandler,
|
||||||
CustomHttpMessageHandlerFactory = () => GetSocketsHttpHandler(requestConfiguration),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
await using var downloader = new Downloader.DownloadService(downloadOpt);
|
await using var downloader = new DownloadService(downloadOpt);
|
||||||
downloader.DownloadStarted += (sender, value) =>
|
downloader.DownloadStarted += (sender, value) =>
|
||||||
{
|
{
|
||||||
state = state with
|
state = state with
|
||||||
@@ -194,7 +170,8 @@ public class DownloaderHelper
|
|||||||
await downloader.DownloadFileTaskAsync(request.FileUrl, request.FilePath, cancellationToken);
|
await downloader.DownloadFileTaskAsync(request.FileUrl, request.FilePath, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task DownloadSmallFilesAsync(IWebProxy? webProxy, List<FileDownloadRequest> requests, Action<ReadOnlyMemory<FileDownloadState>> onProgress, TimeSpan connectTimeout, CancellationToken cancellationToken = default)
|
public async Task DownloadSmallFilesAsync(List<FileDownloadRequest> requests,
|
||||||
|
Action<ReadOnlyMemory<FileDownloadState>> onProgress, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (requests is not { Count: > 0 })
|
if (requests is not { Count: > 0 })
|
||||||
{
|
{
|
||||||
@@ -211,31 +188,21 @@ public class DownloaderHelper
|
|||||||
}
|
}
|
||||||
var readOnlyStates = new ReadOnlyMemory<FileDownloadState>(states);
|
var readOnlyStates = new ReadOnlyMemory<FileDownloadState>(states);
|
||||||
|
|
||||||
var requestConfiguration = new RequestConfiguration()
|
|
||||||
{
|
|
||||||
ConnectTimeout = (int)connectTimeout.TotalMilliseconds,
|
|
||||||
Proxy = webProxy,
|
|
||||||
|
|
||||||
KeepAlive = true,
|
|
||||||
};
|
|
||||||
using var socketsHttpHandler = GetSocketsHttpHandler(requestConfiguration);
|
|
||||||
|
|
||||||
var parallelOptions = new ParallelOptions
|
var parallelOptions = new ParallelOptions
|
||||||
{
|
{
|
||||||
MaxDegreeOfParallelism = 4,
|
MaxDegreeOfParallelism = 4,
|
||||||
//CancellationToken = cancellationToken,
|
CancellationToken = cancellationToken,
|
||||||
};
|
};
|
||||||
|
|
||||||
await Parallel.ForEachAsync(Enumerable.Range(0, requests.Count), parallelOptions, async (index, parallelCancellationToken) =>
|
await Parallel.ForEachAsync(Enumerable.Range(0, requests.Count), parallelOptions,
|
||||||
|
async (index, parallelCancellationToken) =>
|
||||||
{
|
{
|
||||||
var request = requests[index];
|
var request = requests[index];
|
||||||
var downloadOpt = new DownloadConfiguration()
|
var downloadOpt = new DownloadConfiguration
|
||||||
{
|
{
|
||||||
RequestConfiguration = requestConfiguration,
|
CustomHttpMessageHandlerFactory = GetHandler,
|
||||||
// ReSharper disable once AccessToDisposedClosure
|
|
||||||
CustomHttpMessageHandlerFactory = () => socketsHttpHandler,
|
|
||||||
};
|
};
|
||||||
await using var downloader = new Downloader.DownloadService(downloadOpt);
|
await using var downloader = new DownloadService(downloadOpt);
|
||||||
downloader.DownloadStarted += (sender, value) =>
|
downloader.DownloadStarted += (sender, value) =>
|
||||||
{
|
{
|
||||||
states[index] = states[index] with
|
states[index] = states[index] with
|
||||||
@@ -272,74 +239,20 @@ public class DownloaderHelper
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// https://github.com/bezzad/Downloader/blob/a75a6e431acd6cbba6293f7afdcf676544a09174/src/Downloader/SocketClient.cs#L45
|
private HttpMessageHandler GetHandler()
|
||||||
// There is a risk of MITM attacks
|
|
||||||
// https://github.com/bezzad/Downloader/blob/a75a6e431acd6cbba6293f7afdcf676544a09174/src/Downloader/Extensions/ExceptionHelper.cs#L111
|
|
||||||
private static SocketsHttpHandler GetSocketsHttpHandler(RequestConfiguration config)
|
|
||||||
{
|
{
|
||||||
SocketsHttpHandler handler = new()
|
return Handler;
|
||||||
{
|
|
||||||
AllowAutoRedirect = config.AllowAutoRedirect,
|
|
||||||
MaxAutomaticRedirections = config.MaximumAutomaticRedirections,
|
|
||||||
AutomaticDecompression = config.AutomaticDecompression,
|
|
||||||
PreAuthenticate = config.PreAuthenticate,
|
|
||||||
UseCookies = config.CookieContainer != null,
|
|
||||||
UseProxy = config.Proxy != null,
|
|
||||||
MaxConnectionsPerServer = 1000,
|
|
||||||
PooledConnectionIdleTimeout = config.KeepAliveTimeout,
|
|
||||||
PooledConnectionLifetime = Timeout.InfiniteTimeSpan,
|
|
||||||
EnableMultipleHttp2Connections = true,
|
|
||||||
ConnectTimeout = TimeSpan.FromMilliseconds(config.ConnectTimeout),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Set up the SslClientAuthenticationOptions for custom certificate validation
|
|
||||||
if (config.ClientCertificates?.Count > 0)
|
|
||||||
{
|
|
||||||
handler.SslOptions.ClientCertificates = config.ClientCertificates;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
handler.SslOptions.EnabledSslProtocols = SslProtocols.Tls13 | SslProtocols.Tls12;
|
public void Dispose()
|
||||||
//handler.SslOptions.RemoteCertificateValidationCallback = ExceptionHelper.CertificateValidationCallBack;
|
|
||||||
|
|
||||||
var certificateChainPolicy = CertPemManager.Instance.BuildCertificateChainPolicy();
|
|
||||||
if (certificateChainPolicy != null)
|
|
||||||
{
|
{
|
||||||
handler.SslOptions.CertificateChainPolicy = certificateChainPolicy;
|
if (!DisposeHandler)
|
||||||
handler.SslOptions.RemoteCertificateValidationCallback = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Configure keep-alive
|
|
||||||
if (config.KeepAlive)
|
|
||||||
{
|
{
|
||||||
handler.KeepAlivePingTimeout = config.KeepAliveTimeout;
|
return;
|
||||||
handler.KeepAlivePingPolicy = HttpKeepAlivePingPolicy.WithActiveRequests;
|
|
||||||
}
|
}
|
||||||
|
if (Handler is IDisposable disposableHandler)
|
||||||
// Configure credentials
|
|
||||||
if (config.Credentials != null)
|
|
||||||
{
|
{
|
||||||
handler.Credentials = config.Credentials;
|
disposableHandler.Dispose();
|
||||||
handler.PreAuthenticate = config.PreAuthenticate;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configure cookies
|
|
||||||
if (handler.UseCookies && config.CookieContainer != null)
|
|
||||||
{
|
|
||||||
handler.CookieContainer = config.CookieContainer;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Configure proxy
|
|
||||||
if (handler.UseProxy && config.Proxy != null)
|
|
||||||
{
|
|
||||||
handler.Proxy = config.Proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add expect header
|
|
||||||
if (!string.IsNullOrWhiteSpace(config.Expect))
|
|
||||||
{
|
|
||||||
handler.Expect100ContinueTimeout = TimeSpan.FromSeconds(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
return handler;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,74 +1,242 @@
|
|||||||
using System.Net.Http.Headers;
|
|
||||||
using System.Net.Mime;
|
|
||||||
|
|
||||||
namespace ServiceLib.Helper;
|
namespace ServiceLib.Helper;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class HttpClientHelper
|
public class HttpClientHelper
|
||||||
{
|
{
|
||||||
private static readonly Lazy<HttpClientHelper> _instance = new(() =>
|
private static readonly Lazy<HttpClientHelper> _instance = new(() => new());
|
||||||
{
|
|
||||||
SocketsHttpHandler handler = new() { UseCookies = false };
|
// private readonly SocketsHttpHandler _directHandler = CreateSocketsHttpHandler(false);
|
||||||
HttpClientHelper helper = new(new HttpClient(handler));
|
private readonly Lazy<SocketsHttpHandler> _directHandlerLazy = new(() => CreateSocketsHttpHandler(false));
|
||||||
return helper;
|
private Uri _proxyUri = null!;
|
||||||
});
|
|
||||||
|
|
||||||
public static HttpClientHelper Instance => _instance.Value;
|
public static HttpClientHelper Instance => _instance.Value;
|
||||||
private readonly HttpClient httpClient;
|
|
||||||
|
|
||||||
private HttpClientHelper(HttpClient httpClient)
|
public HttpMessageHandler DirectHandler => new SharedHandlerAdapter(_directHandlerLazy.Value);
|
||||||
|
|
||||||
|
public HttpMessageHandler ProxyHandler
|
||||||
{
|
{
|
||||||
this.httpClient = httpClient;
|
get
|
||||||
|
{
|
||||||
|
var port = AppManager.Instance.GetLocalPort(EInboundProtocol.socks);
|
||||||
|
var proxyUri = new Uri($"socks5://{Global.Loopback}:{port}");
|
||||||
|
if (_proxyUri != proxyUri)
|
||||||
|
{
|
||||||
|
//field?.Dispose();
|
||||||
|
_proxyUri = proxyUri;
|
||||||
|
field = CreateProxySocketsHttpHandler(port);
|
||||||
|
}
|
||||||
|
return new SharedHandlerAdapter(field);
|
||||||
|
}
|
||||||
|
} = null!;
|
||||||
|
|
||||||
|
public HttpMessageHandler GetHandler(bool useProxy)
|
||||||
|
{
|
||||||
|
return useProxy ? ProxyHandler : DirectHandler;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string?> TryGetAsync(string url)
|
public HttpClient GetHttpClient(bool useProxy)
|
||||||
{
|
{
|
||||||
if (url.IsNullOrEmpty())
|
var handler = GetHandler(useProxy);
|
||||||
{
|
var client = new HttpClient(handler);
|
||||||
return null;
|
return client;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static SocketsHttpHandler CreateSocketsHttpHandler(bool useProxy)
|
||||||
|
{
|
||||||
|
SocketsHttpHandler handler;
|
||||||
|
if (!useProxy)
|
||||||
|
{
|
||||||
|
handler = new()
|
||||||
|
{
|
||||||
|
UseProxy = false,
|
||||||
|
UseCookies = false,
|
||||||
|
ConnectTimeout = Global.DirectDownloadConnect,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var port = AppManager.Instance.GetLocalPort(EInboundProtocol.socks);
|
||||||
|
handler = CreateProxySocketsHttpHandler(port);
|
||||||
|
}
|
||||||
|
var certificateChainPolicy = CertPemManager.Instance.BuildCertificateChainPolicy();
|
||||||
|
if (certificateChainPolicy != null)
|
||||||
|
{
|
||||||
|
handler.SslOptions.CertificateChainPolicy = certificateChainPolicy;
|
||||||
|
handler.SslOptions.RemoteCertificateValidationCallback = null;
|
||||||
|
}
|
||||||
|
return handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static SocketsHttpHandler CreateSocketsHttpHandler(bool useProxy, CookieContainer cookieContainer)
|
||||||
|
{
|
||||||
|
var handler = CreateSocketsHttpHandler(useProxy);
|
||||||
|
handler.UseCookies = true;
|
||||||
|
handler.CookieContainer = cookieContainer;
|
||||||
|
return handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static SocketsHttpHandler CreateProxySocketsHttpHandler(IWebProxy proxy)
|
||||||
|
{
|
||||||
|
var handler = new SocketsHttpHandler
|
||||||
|
{
|
||||||
|
UseCookies = false,
|
||||||
|
Proxy = proxy,
|
||||||
|
ConnectTimeout = Global.ProxyDownloadConnect,
|
||||||
|
};
|
||||||
|
var certificateChainPolicy = CertPemManager.Instance.BuildCertificateChainPolicy();
|
||||||
|
if (certificateChainPolicy != null)
|
||||||
|
{
|
||||||
|
handler.SslOptions.CertificateChainPolicy = certificateChainPolicy;
|
||||||
|
handler.SslOptions.RemoteCertificateValidationCallback = null;
|
||||||
|
}
|
||||||
|
return handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static SocketsHttpHandler CreateProxySocketsHttpHandler(int port)
|
||||||
|
{
|
||||||
|
return CreateProxySocketsHttpHandler(new WebProxy($"socks5://{Global.Loopback}:{port}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static SocketsHttpHandler CreateProxySocketsHttpHandler(IWebProxy proxy, CookieContainer cookieContainer)
|
||||||
|
{
|
||||||
|
var handler = CreateProxySocketsHttpHandler(proxy);
|
||||||
|
handler.UseCookies = true;
|
||||||
|
handler.CookieContainer = cookieContainer;
|
||||||
|
return handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static SocketsHttpHandler CreateProxySocketsHttpHandler(int port, CookieContainer cookieContainer)
|
||||||
|
{
|
||||||
|
var handler = CreateProxySocketsHttpHandler(port);
|
||||||
|
handler.UseCookies = true;
|
||||||
|
handler.CookieContainer = cookieContainer;
|
||||||
|
return handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
public HttpMessageHandler CreateFallbackHandler(bool preferProxy)
|
||||||
|
{
|
||||||
|
var primaryHandler = preferProxy ? ProxyHandler : DirectHandler;
|
||||||
|
var fallbackHandler = preferProxy ? DirectHandler : ProxyHandler;
|
||||||
|
return new DualHandlerFallbackHandler(primaryHandler, fallbackHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
public HttpMessageHandler CreateFallbackHandler(bool preferProxy, CookieContainer cookieContainer)
|
||||||
|
{
|
||||||
|
var primaryHandler = preferProxy ? CreateSocketsHttpHandler(true, cookieContainer) : CreateSocketsHttpHandler(false, cookieContainer);
|
||||||
|
var fallbackHandler = preferProxy ? CreateSocketsHttpHandler(false, cookieContainer) : CreateSocketsHttpHandler(true, cookieContainer);
|
||||||
|
return new DualHandlerFallbackHandler(primaryHandler, fallbackHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
private class DualHandlerFallbackHandler(
|
||||||
|
HttpMessageHandler primaryHandler,
|
||||||
|
HttpMessageHandler fallbackHandler)
|
||||||
|
: DelegatingHandler(primaryHandler)
|
||||||
|
{
|
||||||
|
private readonly HttpMessageInvoker _fallbackInvoker = new(fallbackHandler);
|
||||||
|
|
||||||
|
protected override async Task<HttpResponseMessage> SendAsync(
|
||||||
|
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var contentBytes = await BufferContentAsync(request, cancellationToken);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var response = await httpClient.GetAsync(url);
|
var response = await base.SendAsync(request, cancellationToken);
|
||||||
return await response.Content.ReadAsStringAsync();
|
|
||||||
|
if (IsRetriableStatusCode(response.StatusCode))
|
||||||
|
{
|
||||||
|
response.Dispose();
|
||||||
|
ResetRequestContent(request, contentBytes);
|
||||||
|
return await _fallbackInvoker.SendAsync(request, cancellationToken);
|
||||||
}
|
}
|
||||||
catch
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (IsNetworkOrTimeoutException(ex) && !cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
ResetRequestContent(request, contentBytes);
|
||||||
|
return await _fallbackInvoker.SendAsync(request, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<byte[]?> BufferContentAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (request.Content == null)
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
return await request.Content.ReadAsByteArrayAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string?> GetAsync(string url)
|
private static void ResetRequestContent(HttpRequestMessage request, byte[]? contentBytes)
|
||||||
{
|
{
|
||||||
if (url.IsNullOrEmpty())
|
if (contentBytes == null)
|
||||||
{
|
{
|
||||||
return null;
|
return;
|
||||||
}
|
|
||||||
return await httpClient.GetStringAsync(url);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task PutAsync(string url, Dictionary<string, string> headers)
|
var newContent = new ByteArrayContent(contentBytes);
|
||||||
|
|
||||||
|
if (request.Content != null)
|
||||||
{
|
{
|
||||||
var jsonContent = JsonUtils.Serialize(headers);
|
foreach (var header in request.Content.Headers)
|
||||||
var content = new StringContent(jsonContent, Encoding.UTF8, MediaTypeNames.Application.Json);
|
|
||||||
|
|
||||||
await httpClient.PutAsync(url, content);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task PatchAsync(string url, Dictionary<string, string> headers)
|
|
||||||
{
|
{
|
||||||
var myContent = JsonUtils.Serialize(headers);
|
newContent.Headers.TryAddWithoutValidation(header.Key, header.Value);
|
||||||
var buffer = Encoding.UTF8.GetBytes(myContent);
|
}
|
||||||
var byteContent = new ByteArrayContent(buffer);
|
|
||||||
byteContent.Headers.ContentType = new MediaTypeHeaderValue("application/json");
|
|
||||||
|
|
||||||
await httpClient.PatchAsync(url, byteContent);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task DeleteAsync(string url)
|
request.Content = newContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsNetworkOrTimeoutException(Exception ex)
|
||||||
{
|
{
|
||||||
await httpClient.DeleteAsync(url);
|
return ex is HttpRequestException or TaskCanceledException;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsRetriableStatusCode(HttpStatusCode statusCode) =>
|
||||||
|
statusCode is HttpStatusCode.BadGateway // 502
|
||||||
|
or HttpStatusCode.ServiceUnavailable // 503
|
||||||
|
or HttpStatusCode.GatewayTimeout; // 504
|
||||||
|
|
||||||
|
protected override void Dispose(bool disposing)
|
||||||
|
{
|
||||||
|
if (disposing)
|
||||||
|
{
|
||||||
|
_fallbackInvoker.Dispose();
|
||||||
|
}
|
||||||
|
base.Dispose(disposing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SharedHandlerAdapter(HttpMessageHandler sharedHandler) : HttpMessageHandler
|
||||||
|
{
|
||||||
|
private readonly HttpMessageInvoker _invoker = new(
|
||||||
|
sharedHandler,
|
||||||
|
disposeHandler: false);
|
||||||
|
|
||||||
|
protected override HttpResponseMessage Send(
|
||||||
|
HttpRequestMessage request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return _invoker.Send(request, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override Task<HttpResponseMessage> SendAsync(
|
||||||
|
HttpRequestMessage request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return _invoker.SendAsync(request, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Dispose(bool disposing)
|
||||||
|
{
|
||||||
|
if (disposing)
|
||||||
|
{
|
||||||
|
_invoker.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
base.Dispose(disposing);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
namespace ServiceLib.Helper;
|
||||||
|
|
||||||
|
public static class HttpRequestHeadersHelper
|
||||||
|
{
|
||||||
|
public static bool TryParse(string? json, out Dictionary<string, string> headers)
|
||||||
|
{
|
||||||
|
headers = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
if (string.IsNullOrWhiteSpace(json))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var document = JsonDocument.Parse(json);
|
||||||
|
if (document.RootElement.ValueKind != JsonValueKind.Object)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var parsed = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
using var request = new HttpRequestMessage { Content = new ByteArrayContent([]) };
|
||||||
|
foreach (var property in document.RootElement.EnumerateObject())
|
||||||
|
{
|
||||||
|
if (property.Value.ValueKind != JsonValueKind.String
|
||||||
|
|| !parsed.TryAdd(property.Name, property.Value.GetString()!)
|
||||||
|
|| !TryAddHeader(request, property.Name, parsed[property.Name]))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
headers = parsed;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is JsonException or ArgumentException or FormatException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static HttpMessageHandler CreateHandler(HttpMessageHandler innerHandler,
|
||||||
|
IReadOnlyDictionary<string, string>? headers)
|
||||||
|
{
|
||||||
|
return headers is { Count: > 0 } ? new RequestHeadersHandler(innerHandler, headers) : innerHandler;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static HttpMessageHandler CreateHandler(HttpMessageHandler innerHandler,
|
||||||
|
string userAgent, string acceptHeader,
|
||||||
|
IReadOnlyDictionary<string, string>? headers)
|
||||||
|
{
|
||||||
|
var allHeaders = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
allHeaders["User-Agent"] = userAgent;
|
||||||
|
allHeaders["Accept"] = acceptHeader;
|
||||||
|
|
||||||
|
if (headers is { Count: > 0 })
|
||||||
|
{
|
||||||
|
foreach (var header in headers)
|
||||||
|
{
|
||||||
|
allHeaders[header.Key] = header.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new RequestHeadersHandler(innerHandler, allHeaders);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryAddHeader(HttpRequestMessage request, string name, string value)
|
||||||
|
{
|
||||||
|
if (value.Any(c => char.IsControl(c) && c != '\t'))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return request.Headers.TryAddWithoutValidation(name, value)
|
||||||
|
|| request.Content!.Headers.TryAddWithoutValidation(name, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class RequestHeadersHandler(
|
||||||
|
HttpMessageHandler innerHandler,
|
||||||
|
IReadOnlyDictionary<string, string> headers)
|
||||||
|
: DelegatingHandler(innerHandler)
|
||||||
|
{
|
||||||
|
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var customHeaders = new HttpRequestMessage { Content = new ByteArrayContent([]) };
|
||||||
|
foreach (var header in headers)
|
||||||
|
{
|
||||||
|
if (!TryAddHeader(customHeaders, header.Key, header.Value))
|
||||||
|
{
|
||||||
|
throw new FormatException(ResUI.SubRequestHeadersInvalid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply after each downloader's defaults, replacing headers without changing their values.
|
||||||
|
foreach (var header in customHeaders.Headers.NonValidated)
|
||||||
|
{
|
||||||
|
request.Headers.Remove(header.Key);
|
||||||
|
request.Headers.TryAddWithoutValidation(header.Key, header.Value);
|
||||||
|
}
|
||||||
|
foreach (var header in customHeaders.Content.Headers.NonValidated)
|
||||||
|
{
|
||||||
|
request.Content ??= new ByteArrayContent([]);
|
||||||
|
request.Content.Headers.Remove(header.Key);
|
||||||
|
request.Content.Headers.TryAddWithoutValidation(header.Key, header.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return base.SendAsync(request, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
using System.Collections;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
|
||||||
namespace ServiceLib.Helper;
|
namespace ServiceLib.Helper;
|
||||||
|
|
||||||
@@ -18,42 +18,73 @@ public sealed class SQLiteHelper
|
|||||||
_dbAsync = new SQLiteAsyncConnection(_connstr, false);
|
_dbAsync = new SQLiteAsyncConnection(_connstr, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
public CreateTableResult CreateTable<T>()
|
public CreateTableResult CreateTable<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>()
|
||||||
{
|
{
|
||||||
return _db.CreateTable<T>();
|
return _db.CreateTable<T>();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<int> InsertAllAsync(IEnumerable models)
|
public async Task<int> InsertAllAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>(IEnumerable<T> models)
|
||||||
{
|
{
|
||||||
return await _dbAsync.InsertAllAsync(models, runInTransaction: true).ConfigureAwait(false);
|
var count = 0;
|
||||||
|
await _dbAsync.RunInTransactionAsync(connection =>
|
||||||
|
{
|
||||||
|
foreach (var model in models)
|
||||||
|
{
|
||||||
|
count += connection.Insert(model, typeof(T));
|
||||||
|
}
|
||||||
|
}).ConfigureAwait(false);
|
||||||
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<int> InsertAsync(object model)
|
public async Task<int> ReplaceAllAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>(IEnumerable<T> models)
|
||||||
{
|
{
|
||||||
return await _dbAsync.InsertAsync(model);
|
var count = 0;
|
||||||
|
await _dbAsync.RunInTransactionAsync(connection =>
|
||||||
|
{
|
||||||
|
foreach (var model in models)
|
||||||
|
{
|
||||||
|
count += connection.InsertOrReplace(model, typeof(T));
|
||||||
|
}
|
||||||
|
}).ConfigureAwait(false);
|
||||||
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<int> ReplaceAsync(object model)
|
public async Task<int> InsertAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>(T model)
|
||||||
{
|
{
|
||||||
return await _dbAsync.InsertOrReplaceAsync(model);
|
return await _dbAsync.InsertAsync(model, typeof(T));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<int> UpdateAsync(object model)
|
public async Task<int> ReplaceAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>(T model)
|
||||||
{
|
{
|
||||||
return await _dbAsync.UpdateAsync(model);
|
return await _dbAsync.InsertOrReplaceAsync(model, typeof(T));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<int> UpdateAllAsync(IEnumerable models)
|
public async Task<int> UpdateAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>(T model)
|
||||||
{
|
{
|
||||||
return await _dbAsync.UpdateAllAsync(models, runInTransaction: true).ConfigureAwait(false);
|
return await _dbAsync.UpdateAsync(model, typeof(T));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<int> DeleteAsync(object model)
|
public async Task<int> UpdateAllAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>(IEnumerable<T> models)
|
||||||
{
|
{
|
||||||
return await _dbAsync.DeleteAsync(model);
|
var count = 0;
|
||||||
|
await _dbAsync.RunInTransactionAsync(connection =>
|
||||||
|
{
|
||||||
|
foreach (var model in models)
|
||||||
|
{
|
||||||
|
count += connection.Update(model, typeof(T));
|
||||||
|
}
|
||||||
|
}).ConfigureAwait(false);
|
||||||
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<int> DeleteAllAsync<T>()
|
public async Task<int> DeleteAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>(T model)
|
||||||
|
{
|
||||||
|
var mapping = await _dbAsync.GetMappingAsync(typeof(T));
|
||||||
|
var primaryKey = mapping.PK ?? throw new NotSupportedException($"Cannot delete {mapping.TableName}: it has no PK");
|
||||||
|
return await _dbAsync.DeleteAsync(primaryKey.GetValue(model), mapping);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> DeleteAllAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>()
|
||||||
{
|
{
|
||||||
return await _dbAsync.DeleteAllAsync<T>();
|
return await _dbAsync.DeleteAllAsync<T>();
|
||||||
}
|
}
|
||||||
@@ -63,27 +94,40 @@ public sealed class SQLiteHelper
|
|||||||
return await _dbAsync.ExecuteAsync(sql);
|
return await _dbAsync.ExecuteAsync(sql);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<T>> QueryAsync<T>(string sql) where T : new()
|
public async Task<List<T>> QueryAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>(string sql) where T : new()
|
||||||
{
|
{
|
||||||
return await _dbAsync.QueryAsync<T>(sql);
|
return await _dbAsync.QueryAsync<T>(sql);
|
||||||
}
|
}
|
||||||
|
|
||||||
public AsyncTableQuery<T> TableAsync<T>() where T : new()
|
public AsyncTableQuery<T> TableAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>() where T : new()
|
||||||
{
|
{
|
||||||
return _dbAsync.Table<T>();
|
return _dbAsync.Table<T>();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task DisposeDbConnectionAsync()
|
public async Task DisposeDbConnectionAsync()
|
||||||
{
|
{
|
||||||
await Task.Factory.StartNew(() =>
|
await Task.Run(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
{
|
{
|
||||||
_db?.Close();
|
_db?.Close();
|
||||||
_db?.Dispose();
|
_db?.Dispose();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
_db = null;
|
_db = null;
|
||||||
|
}
|
||||||
|
|
||||||
_dbAsync?.GetConnection()?.Close();
|
try
|
||||||
_dbAsync?.GetConnection()?.Dispose();
|
{
|
||||||
|
var conn = _dbAsync?.GetConnection();
|
||||||
|
conn?.Close();
|
||||||
|
conn?.Dispose();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
_dbAsync = null;
|
_dbAsync = null;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,14 +28,13 @@ public class CertPemManager
|
|||||||
/// Get certificate in PEM format from a server with CA pinning validation
|
/// Get certificate in PEM format from a server with CA pinning validation
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<(string?, string?)> GetCertPemAsync(string target, string serverName,
|
public async Task<(string?, string?)> GetCertPemAsync(string target, string serverName,
|
||||||
List<string>? verifyPeerCertByName = null, int timeout = 4)
|
List<string>? verifyPeerCertByName = null)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var (domain, _, port, _) = Utils.ParseUrl(target);
|
var (domain, _, port, _) = Utils.ParseUrl(target);
|
||||||
|
|
||||||
using var cts = new CancellationTokenSource();
|
using var cts = new CancellationTokenSource(Global.LocalFetch);
|
||||||
cts.CancelAfter(TimeSpan.FromSeconds(timeout));
|
|
||||||
|
|
||||||
using var client = new TcpClient();
|
using var client = new TcpClient();
|
||||||
await client.ConnectAsync(domain, port > 0 ? port : 443, cts.Token);
|
await client.ConnectAsync(domain, port > 0 ? port : 443, cts.Token);
|
||||||
@@ -63,8 +62,8 @@ public class CertPemManager
|
|||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
Logging.SaveLog(_tag, new TimeoutException($"Connection timeout after {timeout} seconds"));
|
Logging.SaveLog(_tag, new TimeoutException($"Connection timeout after {Global.LocalFetch.TotalSeconds} seconds"));
|
||||||
return (null, $"Connection timeout after {timeout} seconds");
|
return (null, $"Connection timeout after {Global.LocalFetch.TotalSeconds} seconds");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -77,15 +76,14 @@ public class CertPemManager
|
|||||||
/// Get certificate chain in PEM format from a server with CA pinning validation
|
/// Get certificate chain in PEM format from a server with CA pinning validation
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<(List<string>, string?)> GetCertChainPemAsync(string target, string serverName,
|
public async Task<(List<string>, string?)> GetCertChainPemAsync(string target, string serverName,
|
||||||
List<string>? verifyPeerCertByName = null, int timeout = 4)
|
List<string>? verifyPeerCertByName = null)
|
||||||
{
|
{
|
||||||
var pemList = new List<string>();
|
var pemList = new List<string>();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var (domain, _, port, _) = Utils.ParseUrl(target);
|
var (domain, _, port, _) = Utils.ParseUrl(target);
|
||||||
|
|
||||||
using var cts = new CancellationTokenSource();
|
using var cts = new CancellationTokenSource(Global.LocalFetch);
|
||||||
cts.CancelAfter(TimeSpan.FromSeconds(timeout));
|
|
||||||
|
|
||||||
using var client = new TcpClient();
|
using var client = new TcpClient();
|
||||||
await client.ConnectAsync(domain, port > 0 ? port : 443, cts.Token);
|
await client.ConnectAsync(domain, port > 0 ? port : 443, cts.Token);
|
||||||
@@ -116,8 +114,8 @@ public class CertPemManager
|
|||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
Logging.SaveLog(_tag, new TimeoutException($"Connection timeout after {timeout} seconds"));
|
Logging.SaveLog(_tag, new TimeoutException($"Connection timeout after {Global.LocalFetch.TotalSeconds} seconds"));
|
||||||
return (pemList, $"Connection timeout after {timeout} seconds");
|
return (pemList, $"Connection timeout after {Global.LocalFetch.TotalSeconds} seconds");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,155 +1,222 @@
|
|||||||
using static ServiceLib.Models.Dto.ClashProxies;
|
using System.Net.Http.Headers;
|
||||||
|
|
||||||
namespace ServiceLib.Manager;
|
namespace ServiceLib.Manager;
|
||||||
|
|
||||||
public sealed class ClashApiManager
|
public sealed class ClashApiManager
|
||||||
{
|
{
|
||||||
|
private const string _tag = "ClashApiManager";
|
||||||
private static readonly Lazy<ClashApiManager> instance = new(() => new());
|
private static readonly Lazy<ClashApiManager> instance = new(() => new());
|
||||||
public static ClashApiManager Instance => instance.Value;
|
public static ClashApiManager Instance => instance.Value;
|
||||||
|
|
||||||
private static readonly string _tag = "ClashApiHandler";
|
private static string ApiUrl => $"{Global.HttpProtocol}{Global.Loopback}:{AppManager.Instance.StatePort2}";
|
||||||
private Dictionary<string, ProxiesItem>? _proxies;
|
|
||||||
public Dictionary<string, object> ProfileContent { get; set; }
|
|
||||||
|
|
||||||
public async Task<Tuple<ClashProxies, ClashProviders>?> GetClashProxiesAsync()
|
public async Task<ClashItem?> GetProxies()
|
||||||
{
|
{
|
||||||
|
using var httpClient = HttpClientHelper.Instance.GetHttpClient(false);
|
||||||
for (var i = 0; i < 3; i++)
|
for (var i = 0; i < 3; i++)
|
||||||
{
|
{
|
||||||
var url = $"{GetApiUrl()}/proxies";
|
ClashProxies? clashProxies = null;
|
||||||
var result = await HttpClientHelper.Instance.TryGetAsync(url);
|
ClashProviders? clashProviders = null;
|
||||||
var clashProxies = JsonUtils.Deserialize<ClashProxies>(result);
|
try
|
||||||
|
|
||||||
var url2 = $"{GetApiUrl()}/providers/proxies";
|
|
||||||
var result2 = await HttpClientHelper.Instance.TryGetAsync(url2);
|
|
||||||
var clashProviders = JsonUtils.Deserialize<ClashProviders>(result2);
|
|
||||||
|
|
||||||
if (clashProxies != null || clashProviders != null)
|
|
||||||
{
|
{
|
||||||
_proxies = clashProxies?.proxies;
|
var url = $"{ApiUrl}/proxies";
|
||||||
return new Tuple<ClashProxies, ClashProviders>(clashProxies, clashProviders);
|
var resultTask = httpClient.GetStringAsync(url);
|
||||||
|
|
||||||
|
var url2 = $"{ApiUrl}/providers/proxies";
|
||||||
|
var result2Task = httpClient.GetStringAsync(url2);
|
||||||
|
|
||||||
|
await Task.WhenAll(resultTask, result2Task);
|
||||||
|
|
||||||
|
var result = await resultTask;
|
||||||
|
var result2 = await result2Task;
|
||||||
|
clashProxies = JsonUtils.Deserialize<ClashProxies>(result);
|
||||||
|
clashProviders = JsonUtils.Deserialize<ClashProviders>(result2);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logging.SaveLog(_tag, ex);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (clashProxies is null && clashProviders is null)
|
||||||
|
{
|
||||||
await Task.Delay(2000);
|
await Task.Delay(2000);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
var item = new ClashItem();
|
||||||
|
if (clashProxies?.proxies is { Count: > 0 } proxies)
|
||||||
|
{
|
||||||
|
item = item with { Proxies = proxies };
|
||||||
}
|
}
|
||||||
|
if (clashProviders?.providers is { Count: > 0 } providers)
|
||||||
public void ClashProxiesDelayTest(bool blAll, List<ClashProxyModel> lstProxy, Func<ClashProxyModel?, string, Task> updateFunc)
|
|
||||||
{
|
{
|
||||||
Task.Run(async () =>
|
foreach (var provider in providers)
|
||||||
{
|
{
|
||||||
if (blAll)
|
foreach (var proxy in provider.Value.proxies ?? [])
|
||||||
{
|
{
|
||||||
if (_proxies == null)
|
if (string.IsNullOrEmpty(proxy.name)
|
||||||
{
|
|| !item.Proxies.TryAdd(proxy.name, proxy))
|
||||||
await GetClashProxiesAsync();
|
|
||||||
}
|
|
||||||
lstProxy = [];
|
|
||||||
lstProxy.AddRange(from kv in _proxies ?? []
|
|
||||||
where !Global.notAllowTestType.Contains(kv.Value.type?.ToLower())
|
|
||||||
select new ClashProxyModel()
|
|
||||||
{
|
|
||||||
Name = kv.Value.name,
|
|
||||||
Type = kv.Value.type?.ToLower(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lstProxy is not { Count: > 0 })
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var urlBase = $"{GetApiUrl()}/proxies";
|
|
||||||
urlBase += @"/{0}/delay?timeout=10000&url=" + AppManager.Instance.Config.SpeedTestItem.SpeedPingTestUrl;
|
|
||||||
|
|
||||||
var tasks = new List<Task>();
|
|
||||||
foreach (var it in lstProxy)
|
|
||||||
{
|
|
||||||
if (Global.notAllowTestType.Contains(it.Type.ToLower()))
|
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
var name = it.Name;
|
item.ProviderIndexMap.Add(proxy.name, provider.Key);
|
||||||
var url = string.Format(urlBase, name);
|
}
|
||||||
tasks.Add(Task.Run(async () =>
|
|
||||||
{
|
|
||||||
var result = await HttpClientHelper.Instance.TryGetAsync(url);
|
|
||||||
await updateFunc?.Invoke(it, result);
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
await Task.WhenAll(tasks);
|
|
||||||
await Task.Delay(1000);
|
|
||||||
await updateFunc?.Invoke(null, "");
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<ProxiesItem>? GetClashProxyGroups()
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> TestDelay(string name, ClashItem? clashItem)
|
||||||
|
{
|
||||||
|
if (clashItem?.ProviderIndexMap.TryGetValue(name, out var providerName) == true)
|
||||||
|
{
|
||||||
|
return await TestProviderProxyDelay(name, providerName);
|
||||||
|
}
|
||||||
|
return await TestProxyDelay(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<int> TestProxyDelay(string name)
|
||||||
|
{
|
||||||
|
var url = $"{ApiUrl}/proxies/{Utils.UrlEncode(name)}/delay?timeout=10000&url=" +
|
||||||
|
Utils.UrlEncode(AppManager.Instance.Config.SpeedTestItem.SpeedPingTestUrl);
|
||||||
|
return await SendTestRequest(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<int> TestProviderProxyDelay(string name, string providerName)
|
||||||
|
{
|
||||||
|
var url =
|
||||||
|
$"{ApiUrl}/providers/proxies/{Utils.UrlEncode(providerName)}/{Utils.UrlEncode(name)}/healthcheck?timeout=10000&url=" +
|
||||||
|
Utils.UrlEncode(AppManager.Instance.Config.SpeedTestItem.SpeedPingTestUrl);
|
||||||
|
return await SendTestRequest(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<int> SendTestRequest(string url)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var fileContent = ProfileContent;
|
using var httpClient = HttpClientHelper.Instance.GetHttpClient(false);
|
||||||
if (fileContent is null || fileContent?.ContainsKey("proxy-groups") == false)
|
var result = await httpClient.GetStringAsync(url);
|
||||||
|
var jsonObject = JsonUtils.ParseJson(result) as JsonObject;
|
||||||
|
return jsonObject?["delay"] is { } n && n.GetValueKind() == JsonValueKind.Number &&
|
||||||
|
n.GetValue<JsonElement>().TryGetInt32(out var v)
|
||||||
|
? v
|
||||||
|
: -1;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logging.SaveLog(_tag, ex);
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SetActiveProxy(string groupName, string nodeName)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var httpClient = HttpClientHelper.Instance.GetHttpClient(false);
|
||||||
|
var url = $"{ApiUrl}/proxies/{Utils.UrlEncode(groupName)}";
|
||||||
|
var jsonObject = new JsonObject
|
||||||
|
{
|
||||||
|
["name"] = nodeName,
|
||||||
|
};
|
||||||
|
var jsonString = JsonUtils.Serialize(jsonObject);
|
||||||
|
var content = new StringContent(jsonString, Encoding.UTF8, "application/json");
|
||||||
|
await httpClient.PutAsync(url, content);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logging.SaveLog(_tag, ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateClashMode(string mode)
|
||||||
|
{
|
||||||
|
var jsonObject = new JsonObject
|
||||||
|
{
|
||||||
|
["mode"] = mode,
|
||||||
|
};
|
||||||
|
await UpdateConfig(jsonObject);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateConfig(JsonObject jsonObject)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var urlBase = $"{ApiUrl}/configs";
|
||||||
|
|
||||||
|
using var httpClient = HttpClientHelper.Instance.GetHttpClient(false);
|
||||||
|
var jsonString = JsonUtils.Serialize(jsonObject);
|
||||||
|
var buffer = Encoding.UTF8.GetBytes(jsonString);
|
||||||
|
var byteContent = new ByteArrayContent(buffer);
|
||||||
|
byteContent.Headers.ContentType = new MediaTypeHeaderValue("application/json");
|
||||||
|
await httpClient.PatchAsync(urlBase, byteContent);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logging.SaveLog(_tag, ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<string>> GetClashModes()
|
||||||
|
{
|
||||||
|
var jsonNode = await GetConfig();
|
||||||
|
if ((jsonNode?["mode-list"] ?? jsonNode?["modes"]) is not JsonArray { Count: > 0 } modesArray)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
var modes = new List<string>();
|
||||||
|
foreach (var mode in modesArray)
|
||||||
|
{
|
||||||
|
if (mode is JsonValue jsonValue && jsonValue.GetValueKind() == JsonValueKind.String)
|
||||||
|
{
|
||||||
|
modes.Add(jsonValue.GetValue<string>());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return modes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string?> GetClashMode()
|
||||||
|
{
|
||||||
|
var jsonNode = await GetConfig();
|
||||||
|
if (jsonNode["mode"] is not JsonValue jsonValue || jsonValue.GetValueKind() != JsonValueKind.String)
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return JsonUtils.Deserialize<List<ProxiesItem>>(JsonUtils.Serialize(fileContent["proxy-groups"]));
|
return jsonValue.GetValue<string>();
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Logging.SaveLog(_tag, ex);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task ClashSetActiveProxy(string name, string nameNode)
|
public async Task<JsonObject> GetConfig()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var url = $"{GetApiUrl()}/proxies/{name}";
|
var url = $"{ApiUrl}/configs";
|
||||||
var headers = new Dictionary<string, string>();
|
using var httpClient = HttpClientHelper.Instance.GetHttpClient(false);
|
||||||
headers.Add("name", nameNode);
|
var result = await httpClient.GetStringAsync(url);
|
||||||
await HttpClientHelper.Instance.PutAsync(url, headers);
|
var jsonNode = JsonUtils.ParseJson(result);
|
||||||
|
if (jsonNode is not JsonObject jsonObject)
|
||||||
|
{
|
||||||
|
return new JsonObject();
|
||||||
|
}
|
||||||
|
return jsonObject;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Logging.SaveLog(_tag, ex);
|
Logging.SaveLog(_tag, ex);
|
||||||
}
|
}
|
||||||
|
return new JsonObject();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task ClashConfigUpdate(Dictionary<string, string> headers)
|
public async Task<ClashConnections?> GetConnections()
|
||||||
{
|
|
||||||
if (_proxies == null)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var urlBase = $"{GetApiUrl()}/configs";
|
|
||||||
|
|
||||||
await HttpClientHelper.Instance.PatchAsync(urlBase, headers);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task ClashConfigReload(string filePath)
|
|
||||||
{
|
|
||||||
await ClashConnectionClose("");
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var url = $"{GetApiUrl()}/configs?force=true";
|
|
||||||
var headers = new Dictionary<string, string>();
|
|
||||||
headers.Add("path", filePath);
|
|
||||||
await HttpClientHelper.Instance.PutAsync(url, headers);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Logging.SaveLog(_tag, ex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<ClashConnections?> GetClashConnectionsAsync()
|
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var url = $"{GetApiUrl()}/connections";
|
var url = $"{ApiUrl}/connections";
|
||||||
var result = await HttpClientHelper.Instance.TryGetAsync(url);
|
using var httpClient = HttpClientHelper.Instance.GetHttpClient(false);
|
||||||
|
var result = await httpClient.GetStringAsync(url);
|
||||||
var clashConnections = JsonUtils.Deserialize<ClashConnections>(result);
|
var clashConnections = JsonUtils.Deserialize<ClashConnections>(result);
|
||||||
|
|
||||||
return clashConnections;
|
return clashConnections;
|
||||||
@@ -162,21 +229,17 @@ public sealed class ClashApiManager
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task ClashConnectionClose(string id)
|
public async Task CloseConnection(string id)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var url = $"{GetApiUrl()}/connections/{id}";
|
var url = $"{ApiUrl}/connections/{id}";
|
||||||
await HttpClientHelper.Instance.DeleteAsync(url);
|
using var httpClient = HttpClientHelper.Instance.GetHttpClient(false);
|
||||||
|
await httpClient.DeleteAsync(url);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Logging.SaveLog(_tag, ex);
|
Logging.SaveLog(_tag, ex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private string GetApiUrl()
|
|
||||||
{
|
|
||||||
return $"{Global.HttpProtocol}{Global.Loopback}:{AppManager.Instance.StatePort2}";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -229,6 +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),
|
||||||
Match = "sing-box",
|
Match = "sing-box",
|
||||||
VersionArg = "version",
|
VersionArg = "version",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -216,7 +216,7 @@ public class CoreManager
|
|||||||
await _updateFunc?.Invoke(notify, msg);
|
await _updateFunc?.Invoke(notify, msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task WaitForProxyPort(CoreConfigContext? preContext, int timeoutMs = 5000)
|
private static async Task WaitForProxyPort(CoreConfigContext? preContext)
|
||||||
{
|
{
|
||||||
if (preContext is null)
|
if (preContext is null)
|
||||||
{
|
{
|
||||||
@@ -227,7 +227,7 @@ public class CoreManager
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
using var rootCts = new CancellationTokenSource(TimeSpan.FromMilliseconds(timeoutMs));
|
using var rootCts = new CancellationTokenSource(Global.LocalFetch);
|
||||||
var rootToken = rootCts.Token;
|
var rootToken = rootCts.Token;
|
||||||
|
|
||||||
var port = preContext.Node.Port;
|
var port = preContext.Node.Port;
|
||||||
|
|||||||
@@ -0,0 +1,523 @@
|
|||||||
|
using System.Net.Http.Headers;
|
||||||
|
|
||||||
|
namespace ServiceLib.Manager;
|
||||||
|
|
||||||
|
public class EndpointResolveManager
|
||||||
|
{
|
||||||
|
public static IDnsResolver BuildDnsClient(string dnsServer)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(dnsServer))
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(dnsServer));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dnsServer is "local" or "localhost" or "system" or "dhcp" ||
|
||||||
|
dnsServer.StartsWith("127.0.0.1") ||
|
||||||
|
dnsServer.StartsWith("::1"))
|
||||||
|
{
|
||||||
|
return new SystemDnsResolver();
|
||||||
|
}
|
||||||
|
|
||||||
|
var (domain, scheme, port, path) = Utils.ParseUrl(dnsServer);
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(scheme))
|
||||||
|
{
|
||||||
|
return new UdpDnsResolver(new IPEndPoint(IPAddress.Parse(domain), 53));
|
||||||
|
}
|
||||||
|
|
||||||
|
var processedScheme = scheme.ToLowerInvariant().Replace("+local", "");
|
||||||
|
|
||||||
|
switch (processedScheme)
|
||||||
|
{
|
||||||
|
case "udp":
|
||||||
|
return new UdpDnsResolver(new IPEndPoint(IPAddress.Parse(domain), port != 0 ? port : 53));
|
||||||
|
|
||||||
|
case "tcp":
|
||||||
|
return new TcpDnsResolver(new IPEndPoint(IPAddress.Parse(domain), port != 0 ? port : 53));
|
||||||
|
|
||||||
|
case "doh" or "https":
|
||||||
|
{
|
||||||
|
var uri = new Uri($"https://{domain}:{(port != 0 ? port : 443)}{path}");
|
||||||
|
var httpClient = new HttpClient(HttpClientHelper.Instance.CreateFallbackHandler(false));
|
||||||
|
return new DohDnsResolver(httpClient, uri);
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new NotSupportedException($"Unsupported DNS scheme: {scheme}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class DnsWire
|
||||||
|
{
|
||||||
|
#region Build DNS Query
|
||||||
|
|
||||||
|
public static byte[] BuildQuery(string host, ushort queryType)
|
||||||
|
{
|
||||||
|
if (queryType is not (1 or 28))
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(queryType));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(host))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Host is empty.", nameof(host));
|
||||||
|
}
|
||||||
|
|
||||||
|
host = host.TrimEnd('.');
|
||||||
|
|
||||||
|
if (host.Length == 0)
|
||||||
|
{
|
||||||
|
host = ".";
|
||||||
|
}
|
||||||
|
|
||||||
|
using var stream = new MemoryStream();
|
||||||
|
|
||||||
|
// Header
|
||||||
|
WriteUInt16(stream, 0);
|
||||||
|
WriteUInt16(stream, 0x0100);
|
||||||
|
WriteUInt16(stream, 1);
|
||||||
|
WriteUInt16(stream, 0);
|
||||||
|
WriteUInt16(stream, 0);
|
||||||
|
WriteUInt16(stream, 0);
|
||||||
|
|
||||||
|
// Question: QNAME
|
||||||
|
if (host != ".")
|
||||||
|
{
|
||||||
|
var labels = host.Split('.');
|
||||||
|
foreach (var label in labels)
|
||||||
|
{
|
||||||
|
if (label.Length is < 1 or > 63)
|
||||||
|
{
|
||||||
|
throw new ArgumentException($"Invalid DNS label length: {label.Length}.", nameof(host));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var c in label)
|
||||||
|
{
|
||||||
|
if (c > 0x7F)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Non-ASCII domain names must be converted to A-labels first.",
|
||||||
|
nameof(host));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stream.WriteByte((byte)label.Length);
|
||||||
|
|
||||||
|
var labelBytes = Encoding.ASCII.GetBytes(label);
|
||||||
|
stream.Write(labelBytes, 0, labelBytes.Length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stream.WriteByte(0);
|
||||||
|
|
||||||
|
// Question: QTYPE & QCLASS
|
||||||
|
WriteUInt16(stream, queryType);
|
||||||
|
WriteUInt16(stream, 1);
|
||||||
|
|
||||||
|
return stream.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteUInt16(Stream stream, ushort value)
|
||||||
|
{
|
||||||
|
stream.WriteByte((byte)(value >> 8));
|
||||||
|
stream.WriteByte((byte)(value & 0xFF));
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Parse DNS Response
|
||||||
|
|
||||||
|
public static List<IPAddress> ParseAddresses(
|
||||||
|
ReadOnlySpan<byte> message,
|
||||||
|
ushort expectedType)
|
||||||
|
{
|
||||||
|
return ParseResponse(message, expectedType).Addresses;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static DnsResponse ParseResponse(
|
||||||
|
ReadOnlySpan<byte> message,
|
||||||
|
ushort expectedType)
|
||||||
|
{
|
||||||
|
if (expectedType is not (1 or 28))
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(expectedType));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.Length < 12)
|
||||||
|
{
|
||||||
|
throw new InvalidDataException("DNS message is too short.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var flags = ReadUInt16(message, 2);
|
||||||
|
var questionCount = ReadUInt16(message, 4);
|
||||||
|
var answerCount = ReadUInt16(message, 6);
|
||||||
|
|
||||||
|
// QR = 1
|
||||||
|
if ((flags & 0x8000) == 0)
|
||||||
|
{
|
||||||
|
throw new InvalidDataException("Not a DNS response.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// RCODE
|
||||||
|
var rcode = flags & 0x000F;
|
||||||
|
if (rcode != 0)
|
||||||
|
{
|
||||||
|
return new DnsResponse([], null);
|
||||||
|
}
|
||||||
|
|
||||||
|
var offset = 12;
|
||||||
|
|
||||||
|
// Parse the Question section to obtain the query name.
|
||||||
|
string? queryName = null;
|
||||||
|
for (var i = 0; i < questionCount; i++)
|
||||||
|
{
|
||||||
|
var name = ReadName(message, offset, out offset);
|
||||||
|
queryName ??= NormalizeName(name);
|
||||||
|
|
||||||
|
if (offset + 4 > message.Length)
|
||||||
|
{
|
||||||
|
throw new InvalidDataException("Invalid DNS question.");
|
||||||
|
}
|
||||||
|
|
||||||
|
offset += 4; // QTYPE + QCLASS
|
||||||
|
}
|
||||||
|
|
||||||
|
var addresses = new List<IPAddress>();
|
||||||
|
string? cname = null;
|
||||||
|
|
||||||
|
for (var i = 0; i < answerCount; i++)
|
||||||
|
{
|
||||||
|
// NAME
|
||||||
|
var ownerName = ReadName(message, offset, out offset);
|
||||||
|
|
||||||
|
if (offset + 10 > message.Length)
|
||||||
|
{
|
||||||
|
throw new InvalidDataException("Invalid DNS answer.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var type = ReadUInt16(message, offset);
|
||||||
|
var @class = ReadUInt16(message, offset + 2);
|
||||||
|
// TTL
|
||||||
|
var rdLength = ReadUInt16(message, offset + 8);
|
||||||
|
|
||||||
|
offset += 10;
|
||||||
|
|
||||||
|
if (offset + rdLength > message.Length)
|
||||||
|
{
|
||||||
|
throw new InvalidDataException("Invalid DNS RDATA.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (@class != 1)
|
||||||
|
{
|
||||||
|
offset += rdLength;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var ownerMatchesQuery = NamesEqual(ownerName, queryName);
|
||||||
|
var ownerMatchesCname = NamesEqual(ownerName, cname);
|
||||||
|
|
||||||
|
if (type == expectedType)
|
||||||
|
{
|
||||||
|
if (ownerMatchesQuery || ownerMatchesCname)
|
||||||
|
{
|
||||||
|
if (expectedType == 1 && rdLength == 4)
|
||||||
|
{
|
||||||
|
addresses.Add(new IPAddress(
|
||||||
|
message.Slice(offset, 4)));
|
||||||
|
}
|
||||||
|
else if (expectedType == 28 && rdLength == 16)
|
||||||
|
{
|
||||||
|
addresses.Add(new IPAddress(
|
||||||
|
message.Slice(offset, 16)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (type == 5 && ownerMatchesQuery && cname is null)
|
||||||
|
{
|
||||||
|
cname = ReadName(message, offset, out _);
|
||||||
|
}
|
||||||
|
|
||||||
|
offset += rdLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new DnsResponse(addresses, cname);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<List<IPAddress>> ResolveWithCnameAsync(
|
||||||
|
string host,
|
||||||
|
bool ipv6,
|
||||||
|
Func<string, ushort, CancellationToken, Task<byte[]>> queryAsync,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(host))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Host is empty.", nameof(host));
|
||||||
|
}
|
||||||
|
|
||||||
|
var queryType = ipv6 ? (ushort)28 : (ushort)1;
|
||||||
|
var currentName = host.TrimEnd('.');
|
||||||
|
var visited = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
const int maxHops = 8;
|
||||||
|
|
||||||
|
for (var hop = 0; hop < maxHops; hop++)
|
||||||
|
{
|
||||||
|
if (!visited.Add(currentName))
|
||||||
|
{
|
||||||
|
// CNAME loop detected.
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var response = await queryAsync(currentName, queryType, cancellationToken);
|
||||||
|
var result = ParseResponse(response, queryType);
|
||||||
|
|
||||||
|
if (result.Addresses.Count > 0)
|
||||||
|
{
|
||||||
|
return result.Addresses;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(result.Cname))
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
currentName = result.Cname.TrimEnd('.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string ReadName(
|
||||||
|
ReadOnlySpan<byte> message,
|
||||||
|
int offset,
|
||||||
|
out int nextOffset)
|
||||||
|
{
|
||||||
|
var name = new StringBuilder();
|
||||||
|
var position = offset;
|
||||||
|
var endOffset = -1;
|
||||||
|
var pointerCount = 0;
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
if ((uint)position >= (uint)message.Length)
|
||||||
|
{
|
||||||
|
throw new InvalidDataException("Invalid DNS name.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var length = message[position];
|
||||||
|
|
||||||
|
// End of name.
|
||||||
|
if (length == 0)
|
||||||
|
{
|
||||||
|
position++;
|
||||||
|
if (endOffset < 0)
|
||||||
|
{
|
||||||
|
endOffset = position;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compression pointer.
|
||||||
|
if ((length & 0xC0) == 0xC0)
|
||||||
|
{
|
||||||
|
if (position + 1 >= message.Length)
|
||||||
|
{
|
||||||
|
throw new InvalidDataException("Invalid DNS pointer.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (++pointerCount > 64)
|
||||||
|
{
|
||||||
|
throw new InvalidDataException("Too many DNS compression pointers.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endOffset < 0)
|
||||||
|
{
|
||||||
|
endOffset = position + 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
var pointer = ((length & 0x3F) << 8) | message[position + 1];
|
||||||
|
position = pointer;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reserved label types.
|
||||||
|
if ((length & 0xC0) != 0)
|
||||||
|
{
|
||||||
|
throw new InvalidDataException("Invalid DNS label.");
|
||||||
|
}
|
||||||
|
|
||||||
|
position++;
|
||||||
|
if (position + length > message.Length)
|
||||||
|
{
|
||||||
|
throw new InvalidDataException("Invalid DNS label length.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.Length > 0)
|
||||||
|
{
|
||||||
|
name.Append('.');
|
||||||
|
}
|
||||||
|
|
||||||
|
name.Append(Encoding.ASCII.GetString(message.Slice(position, length)));
|
||||||
|
position += length;
|
||||||
|
}
|
||||||
|
|
||||||
|
nextOffset = endOffset;
|
||||||
|
return name.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record DnsResponse(List<IPAddress> Addresses, string? Cname);
|
||||||
|
|
||||||
|
private static string? NormalizeName(string name)
|
||||||
|
{
|
||||||
|
name = name.TrimEnd('.');
|
||||||
|
return name.Length == 0 ? null : name;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool NamesEqual(string? left, string? right)
|
||||||
|
{
|
||||||
|
return left is not null &&
|
||||||
|
right is not null &&
|
||||||
|
string.Equals(
|
||||||
|
left.TrimEnd('.'),
|
||||||
|
right.TrimEnd('.'),
|
||||||
|
StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ushort ReadUInt16(
|
||||||
|
ReadOnlySpan<byte> data,
|
||||||
|
int offset)
|
||||||
|
{
|
||||||
|
return (ushort)(
|
||||||
|
(data[offset] << 8) |
|
||||||
|
data[offset + 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IDnsResolver
|
||||||
|
{
|
||||||
|
public Task<List<IPAddress>> ResolveAsync(
|
||||||
|
string host,
|
||||||
|
bool ipv6,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
public async Task<List<IPAddress>> ResolveIpv46Async(
|
||||||
|
string host,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var ipv4Task = ResolveAsync(host, false, cancellationToken);
|
||||||
|
var ipv6Task = ResolveAsync(host, true, cancellationToken);
|
||||||
|
var results = await Task.WhenAll(ipv4Task, ipv6Task);
|
||||||
|
return results.SelectMany(x => x).ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class UdpDnsResolver(IPEndPoint dnsServer) : IDnsResolver
|
||||||
|
{
|
||||||
|
public async Task<List<IPAddress>> ResolveAsync(
|
||||||
|
string host,
|
||||||
|
bool ipv6,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await DnsWire.ResolveWithCnameAsync(
|
||||||
|
host,
|
||||||
|
ipv6,
|
||||||
|
async (name, queryType, ct) =>
|
||||||
|
{
|
||||||
|
var query = DnsWire.BuildQuery(name, queryType);
|
||||||
|
using var udpClient = new UdpClient();
|
||||||
|
udpClient.Connect(dnsServer);
|
||||||
|
await udpClient.SendAsync(query, query.Length);
|
||||||
|
var result = await udpClient.ReceiveAsync(ct);
|
||||||
|
return result.Buffer;
|
||||||
|
},
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class TcpDnsResolver(IPEndPoint dnsServer) : IDnsResolver
|
||||||
|
{
|
||||||
|
public async Task<List<IPAddress>> ResolveAsync(
|
||||||
|
string host,
|
||||||
|
bool ipv6,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await DnsWire.ResolveWithCnameAsync(
|
||||||
|
host,
|
||||||
|
ipv6,
|
||||||
|
async (name, queryType, ct) =>
|
||||||
|
{
|
||||||
|
var query = DnsWire.BuildQuery(name, queryType);
|
||||||
|
using var tcpClient = new TcpClient();
|
||||||
|
await tcpClient.ConnectAsync(dnsServer.Address, dnsServer.Port, ct);
|
||||||
|
await using var networkStream = tcpClient.GetStream();
|
||||||
|
await networkStream.WriteAsync(query, 0, query.Length, ct);
|
||||||
|
// Read the length of the response (2 bytes)
|
||||||
|
var lengthBuffer = new byte[2];
|
||||||
|
await networkStream.ReadExactlyAsync(lengthBuffer, 0, 2, ct);
|
||||||
|
var responseLength = (lengthBuffer[0] << 8) | lengthBuffer[1];
|
||||||
|
// Read the actual response
|
||||||
|
var responseBuffer = new byte[responseLength];
|
||||||
|
await networkStream.ReadExactlyAsync(responseBuffer, 0, responseLength, ct);
|
||||||
|
return responseBuffer;
|
||||||
|
},
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class DohDnsResolver(HttpClient httpClient, Uri dohServer) : IDnsResolver
|
||||||
|
{
|
||||||
|
public async Task<List<IPAddress>> ResolveAsync(
|
||||||
|
string host,
|
||||||
|
bool ipv6,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await DnsWire.ResolveWithCnameAsync(
|
||||||
|
host,
|
||||||
|
ipv6,
|
||||||
|
async (name, queryType, ct) =>
|
||||||
|
{
|
||||||
|
var query = DnsWire.BuildQuery(name, queryType);
|
||||||
|
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Post, dohServer);
|
||||||
|
request.Content = new ByteArrayContent(query)
|
||||||
|
{
|
||||||
|
Headers =
|
||||||
|
{
|
||||||
|
ContentType = new MediaTypeHeaderValue("application/dns-message"),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/dns-message"));
|
||||||
|
|
||||||
|
using var response =
|
||||||
|
await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
return await response.Content.ReadAsByteArrayAsync(ct);
|
||||||
|
},
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SystemDnsResolver : IDnsResolver
|
||||||
|
{
|
||||||
|
public async Task<List<IPAddress>> ResolveAsync(
|
||||||
|
string host,
|
||||||
|
bool ipv6,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var addresses = await Dns.GetHostAddressesAsync(host, cancellationToken);
|
||||||
|
return addresses.Where(ip => (!ipv6 && ip.AddressFamily == AddressFamily.InterNetwork) ||
|
||||||
|
(ipv6 && ip.AddressFamily == AddressFamily.InterNetworkV6))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<IPAddress>> ResolveIpv46Async(
|
||||||
|
string host,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var addresses = await Dns.GetHostAddressesAsync(host, cancellationToken);
|
||||||
|
return addresses.ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -119,7 +119,7 @@ public class GroupProfileManager
|
|||||||
p != null &&
|
p != null &&
|
||||||
p.IsValid() &&
|
p.IsValid() &&
|
||||||
(!p.ConfigType.IsComplexType() || p.ConfigType == EConfigType.Outbound) &&
|
(!p.ConfigType.IsComplexType() || p.ConfigType == EConfigType.Outbound) &&
|
||||||
(extra.Filter.IsNullOrEmpty() || Regex.IsMatch(p.Remarks, extra.Filter))
|
Utils.IsRegexMatch(p.Remarks, extra.Filter)
|
||||||
)
|
)
|
||||||
.ToList() ?? [];
|
.ToList() ?? [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,36 +2,37 @@ namespace ServiceLib.Manager;
|
|||||||
|
|
||||||
public class PacManager
|
public class PacManager
|
||||||
{
|
{
|
||||||
private static readonly Lazy<PacManager> _instance = new(() => new PacManager());
|
private const string Tag = "PacManager";
|
||||||
public static PacManager Instance => _instance.Value;
|
private CancellationTokenSource? _cts;
|
||||||
|
|
||||||
private int _httpPort;
|
|
||||||
private int _pacPort;
|
private int _pacPort;
|
||||||
private TcpListener? _tcpListener;
|
private TcpListener? _tcpListener;
|
||||||
private byte[] _writeContent;
|
private byte[] _writeContent = [];
|
||||||
private bool _isRunning;
|
|
||||||
private bool _needRestart = true;
|
|
||||||
|
|
||||||
public async Task StartAsync(int httpPort, int pacPort)
|
public async Task StartAsync(int httpPort, int pacPort)
|
||||||
{
|
{
|
||||||
_needRestart = httpPort != _httpPort || pacPort != _pacPort || !_isRunning;
|
var content = await InitText(httpPort);
|
||||||
|
_writeContent = content;
|
||||||
|
|
||||||
_httpPort = httpPort;
|
if (_tcpListener is not null && _pacPort == pacPort)
|
||||||
_pacPort = pacPort;
|
|
||||||
|
|
||||||
await InitText();
|
|
||||||
|
|
||||||
if (_needRestart)
|
|
||||||
{
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
Stop();
|
Stop();
|
||||||
RunListener();
|
var cts = new CancellationTokenSource();
|
||||||
}
|
var listener = TcpListener.Create(pacPort);
|
||||||
|
listener.Start();
|
||||||
|
|
||||||
|
_cts = cts;
|
||||||
|
_pacPort = pacPort;
|
||||||
|
_tcpListener = listener;
|
||||||
|
_ = ListenLoopAsync(listener, cts.Token);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task InitText()
|
private async Task<byte[]> InitText(int httpPort)
|
||||||
{
|
{
|
||||||
var customSystemProxyPacPath = AppManager.Instance.Config.SystemProxyItem?.CustomSystemProxyPacPath;
|
var customSystemProxyPacPath = AppManager.Instance.Config.SystemProxyItem.CustomSystemProxyPacPath;
|
||||||
var fileName = (customSystemProxyPacPath.IsNotEmpty() && File.Exists(customSystemProxyPacPath))
|
var fileName = customSystemProxyPacPath.IsNotEmpty() && File.Exists(customSystemProxyPacPath)
|
||||||
? customSystemProxyPacPath
|
? customSystemProxyPacPath
|
||||||
: Path.Combine(Utils.GetConfigPath(), "pac.txt");
|
: Path.Combine(Utils.GetConfigPath(), "pac.txt");
|
||||||
|
|
||||||
@@ -45,7 +46,7 @@ public class PacManager
|
|||||||
}
|
}
|
||||||
|
|
||||||
var pacText = await File.ReadAllTextAsync(fileName);
|
var pacText = await File.ReadAllTextAsync(fileName);
|
||||||
pacText = pacText.Replace("__PROXY__", $"PROXY 127.0.0.1:{_httpPort};DIRECT;");
|
pacText = pacText.Replace("__PROXY__", $"PROXY 127.0.0.1:{httpPort};DIRECT;");
|
||||||
|
|
||||||
var sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
sb.AppendLine("HTTP/1.0 200 OK");
|
sb.AppendLine("HTTP/1.0 200 OK");
|
||||||
@@ -54,59 +55,49 @@ public class PacManager
|
|||||||
sb.AppendLine("Content-Length:" + Encoding.UTF8.GetByteCount(pacText));
|
sb.AppendLine("Content-Length:" + Encoding.UTF8.GetByteCount(pacText));
|
||||||
sb.AppendLine();
|
sb.AppendLine();
|
||||||
sb.Append(pacText);
|
sb.Append(pacText);
|
||||||
_writeContent = Encoding.UTF8.GetBytes(sb.ToString());
|
return Encoding.UTF8.GetBytes(sb.ToString());
|
||||||
}
|
}
|
||||||
|
|
||||||
private void RunListener()
|
private async Task ListenLoopAsync(TcpListener listener, CancellationToken token)
|
||||||
{
|
{
|
||||||
_tcpListener = TcpListener.Create(_pacPort);
|
var buffer = new byte[1024];
|
||||||
_isRunning = true;
|
try
|
||||||
_tcpListener.Start();
|
|
||||||
Task.Factory.StartNew(async () =>
|
|
||||||
{
|
{
|
||||||
while (_isRunning)
|
while (!token.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (!_tcpListener.Pending())
|
using var client = await listener.AcceptTcpClientAsync(token).ConfigureAwait(false);
|
||||||
{
|
await using var stream = client.GetStream();
|
||||||
await Task.Delay(10);
|
_ = await stream.ReadAsync(buffer, token).ConfigureAwait(false);
|
||||||
continue;
|
await stream.WriteAsync(_writeContent, token).ConfigureAwait(false);
|
||||||
}
|
await stream.FlushAsync(token).ConfigureAwait(false);
|
||||||
|
|
||||||
var client = await _tcpListener.AcceptTcpClientAsync();
|
|
||||||
await Task.Run(() => WriteContent(client));
|
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
// ignored
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, TaskCreationOptions.LongRunning);
|
|
||||||
}
|
}
|
||||||
|
catch (OperationCanceledException) when (token.IsCancellationRequested)
|
||||||
private void WriteContent(TcpClient client)
|
|
||||||
{
|
{
|
||||||
var stream = client.GetStream();
|
}
|
||||||
stream.Write(_writeContent, 0, _writeContent.Length);
|
catch (Exception ex)
|
||||||
stream.Flush();
|
{
|
||||||
|
Logging.SaveLog(Tag, ex);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
listener.Stop();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Stop()
|
public void Stop()
|
||||||
{
|
{
|
||||||
if (_tcpListener == null)
|
_cts?.Cancel();
|
||||||
{
|
_tcpListener?.Stop();
|
||||||
return;
|
_cts?.Dispose();
|
||||||
}
|
_cts = null;
|
||||||
try
|
_pacPort = 0;
|
||||||
{
|
|
||||||
_isRunning = false;
|
|
||||||
_tcpListener.Stop();
|
|
||||||
_tcpListener = null;
|
_tcpListener = null;
|
||||||
}
|
}
|
||||||
catch
|
|
||||||
{
|
|
||||||
// ignored
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,56 +28,51 @@ 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; the save loop deduplicates IDs.
|
||||||
_queIndexIds.Enqueue(indexId);
|
_queIndexIds.Enqueue(indexId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task SaveQueueIndexIds()
|
private async Task SaveQueueIndexIds()
|
||||||
{
|
{
|
||||||
var cnt = _queIndexIds.Count;
|
var lstIds = new HashSet<string>();
|
||||||
if (cnt > 0)
|
while (_queIndexIds.TryDequeue(out var id))
|
||||||
{
|
{
|
||||||
var lstExists = await SQLiteHelper.Instance.TableAsync<ProfileExItem>().ToListAsync();
|
lstIds.Add(id);
|
||||||
List<ProfileExItem> lstInserts = [];
|
}
|
||||||
List<ProfileExItem> lstUpdates = [];
|
|
||||||
|
|
||||||
for (var i = 0; i < cnt; i++)
|
if (lstIds.Count > 0)
|
||||||
|
{
|
||||||
|
List<ProfileExItem> lstSaves = [];
|
||||||
|
|
||||||
|
foreach (var id in lstIds)
|
||||||
{
|
{
|
||||||
var id = _queIndexIds.Dequeue();
|
|
||||||
var item = lstExists.FirstOrDefault(t => t.IndexId == id);
|
|
||||||
var itemNew = _lstProfileEx?.FirstOrDefault(t => t.IndexId == id);
|
var itemNew = _lstProfileEx?.FirstOrDefault(t => t.IndexId == id);
|
||||||
if (itemNew is null)
|
if (itemNew is null)
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item is not null)
|
lstSaves.Add(itemNew);
|
||||||
{
|
|
||||||
lstUpdates.Add(itemNew);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
lstInserts.Add(itemNew);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (lstInserts.Count > 0)
|
if (lstSaves.Count > 0)
|
||||||
{
|
{
|
||||||
await SQLiteHelper.Instance.InsertAllAsync(lstInserts);
|
//Another save may have inserted these rows since the queue was drained.
|
||||||
}
|
await SQLiteHelper.Instance.ReplaceAllAsync(lstSaves);
|
||||||
|
|
||||||
if (lstUpdates.Count > 0)
|
|
||||||
{
|
|
||||||
await SQLiteHelper.Instance.UpdateAllAsync(lstUpdates);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -103,14 +99,27 @@ 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 ");
|
||||||
|
lock (_profileExLock)
|
||||||
|
{
|
||||||
_lstProfileEx = [];
|
_lstProfileEx = [];
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async Task SaveTo()
|
public async Task SaveTo()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -19,12 +19,10 @@ public class TaskManager
|
|||||||
{
|
{
|
||||||
Logging.SaveLog("Setup Scheduled Tasks");
|
Logging.SaveLog("Setup Scheduled Tasks");
|
||||||
|
|
||||||
|
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(1));
|
||||||
var numOfExecuted = 1;
|
var numOfExecuted = 1;
|
||||||
while (true)
|
while (await timer.WaitForNextTickAsync().ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
//1 minute
|
|
||||||
await Task.Delay(1000 * 60);
|
|
||||||
|
|
||||||
//Execute once 1 minute
|
//Execute once 1 minute
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -57,8 +55,8 @@ public class TaskManager
|
|||||||
//Logging.SaveLog("Execute delete expired files");
|
//Logging.SaveLog("Execute delete expired files");
|
||||||
|
|
||||||
FileUtils.DeleteExpiredFiles(Utils.GetBinConfigPath(), DateTime.Now.AddHours(-1), "Test");
|
FileUtils.DeleteExpiredFiles(Utils.GetBinConfigPath(), DateTime.Now.AddHours(-1), "Test");
|
||||||
FileUtils.DeleteExpiredFiles(Utils.GetLogPath(), DateTime.Now.AddMonths(-1));
|
FileUtils.DeleteExpiredFiles(Utils.GetLogPath(), DateTime.Now.AddDays(-7));
|
||||||
FileUtils.DeleteExpiredFiles(Utils.GetTempPath(), DateTime.Now.AddMonths(-1));
|
FileUtils.DeleteExpiredFiles(Utils.GetTempPath(), DateTime.Now.AddDays(-7));
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -103,7 +101,7 @@ public class TaskManager
|
|||||||
|
|
||||||
foreach (var item in lstSubs)
|
foreach (var item in lstSubs)
|
||||||
{
|
{
|
||||||
await SubscriptionHandler.UpdateProcess(_config, item.Id, true, async (success, msg) =>
|
var handler = new SubscriptionHandler(_config, true, async (success, msg) =>
|
||||||
{
|
{
|
||||||
await _updateFunc?.Invoke(success, msg);
|
await _updateFunc?.Invoke(success, msg);
|
||||||
if (success)
|
if (success)
|
||||||
@@ -111,6 +109,7 @@ public class TaskManager
|
|||||||
Logging.SaveLog($"Update subscription end. {msg}");
|
Logging.SaveLog($"Update subscription end. {msg}");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
await handler.UpdateProcess(item.Id);
|
||||||
item.UpdateTime = updateTime;
|
item.UpdateTime = updateTime;
|
||||||
await ConfigHandler.AddSubItem(_config, item);
|
await ConfigHandler.AddSubItem(_config, item);
|
||||||
await Task.Delay(1000);
|
await Task.Delay(1000);
|
||||||
@@ -123,7 +122,8 @@ public class TaskManager
|
|||||||
{
|
{
|
||||||
Logging.SaveLog("Execute update geo files");
|
Logging.SaveLog("Execute update geo files");
|
||||||
|
|
||||||
await new UpdateService(_config, async (success, msg) =>
|
using var downloadService = await DownloadService.CreateDownloadServiceWithCheck(_config.CheckUpdateItem.UpdateViaProxy);
|
||||||
|
await new UpdateService(downloadService, _config, async (success, msg) =>
|
||||||
{
|
{
|
||||||
await _updateFunc?.Invoke(false, msg);
|
await _updateFunc?.Invoke(false, msg);
|
||||||
}).UpdateGeoFileAll();
|
}).UpdateGeoFileAll();
|
||||||
@@ -134,9 +134,10 @@ public class TaskManager
|
|||||||
{
|
{
|
||||||
Logging.SaveLog("Execute check update");
|
Logging.SaveLog("Execute check update");
|
||||||
|
|
||||||
var updateService = new UpdateService(_config, async (success, msg) => await Task.CompletedTask);
|
using var downloadService = await DownloadService.CreateDownloadServiceWithCheck(_config.CheckUpdateItem.UpdateViaProxy);
|
||||||
|
var updateService = new UpdateService(downloadService, _config, async (success, msg) => await Task.CompletedTask);
|
||||||
|
|
||||||
var msgs = await updateService.CheckHasUpdateOnlyAll(_config.CheckUpdateItem.CheckPreReleaseUpdate);
|
var msgs = await updateService.CheckHasUpdateOnlyAll();
|
||||||
foreach (var msg in msgs)
|
foreach (var msg in msgs)
|
||||||
{
|
{
|
||||||
await _updateFunc?.Invoke(false, msg);
|
await _updateFunc?.Invoke(false, msg);
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
namespace ServiceLib.Models.Configs;
|
namespace ServiceLib.Models.Configs;
|
||||||
|
|
||||||
[Serializable]
|
|
||||||
public class Config
|
public class Config
|
||||||
{
|
{
|
||||||
#region property
|
#region property
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
namespace ServiceLib.Models.Configs;
|
namespace ServiceLib.Models.Configs;
|
||||||
|
|
||||||
[Serializable]
|
|
||||||
public class CoreBasicItem
|
public class CoreBasicItem
|
||||||
{
|
{
|
||||||
public bool LogEnabled { get; set; }
|
public bool LogEnabled { get; set; }
|
||||||
@@ -22,7 +21,6 @@ public class CoreBasicItem
|
|||||||
public bool EnableCacheFile4Sbox { get; set; } = true;
|
public bool EnableCacheFile4Sbox { get; set; } = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
[Serializable]
|
|
||||||
public class InItem
|
public class InItem
|
||||||
{
|
{
|
||||||
public int LocalPort { get; set; }
|
public int LocalPort { get; set; }
|
||||||
@@ -38,7 +36,6 @@ public class InItem
|
|||||||
public bool SecondLocalPortEnabled { get; set; }
|
public bool SecondLocalPortEnabled { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
[Serializable]
|
|
||||||
public class KcpItem
|
public class KcpItem
|
||||||
{
|
{
|
||||||
public int Mtu { get; set; }
|
public int Mtu { get; set; }
|
||||||
@@ -54,7 +51,6 @@ public class KcpItem
|
|||||||
public int MaxSendingWindow { get; set; }
|
public int MaxSendingWindow { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
[Serializable]
|
|
||||||
public class GrpcItem
|
public class GrpcItem
|
||||||
{
|
{
|
||||||
public int? IdleTimeout { get; set; }
|
public int? IdleTimeout { get; set; }
|
||||||
@@ -63,7 +59,6 @@ public class GrpcItem
|
|||||||
public int? InitialWindowsSize { get; set; }
|
public int? InitialWindowsSize { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
[Serializable]
|
|
||||||
public class GUIItem
|
public class GUIItem
|
||||||
{
|
{
|
||||||
public bool AutoRun { get; set; }
|
public bool AutoRun { get; set; }
|
||||||
@@ -77,14 +72,12 @@ public class GUIItem
|
|||||||
public string? RootCertProvider { get; set; }
|
public string? RootCertProvider { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
[Serializable]
|
|
||||||
public class MsgUIItem
|
public class MsgUIItem
|
||||||
{
|
{
|
||||||
public string? MainMsgFilter { get; set; }
|
public string? MainMsgFilter { get; set; }
|
||||||
public bool? AutoRefresh { get; set; }
|
public bool? AutoRefresh { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
[Serializable]
|
|
||||||
public class UIItem
|
public class UIItem
|
||||||
{
|
{
|
||||||
public bool EnableAutoAdjustMainLvColWidth { get; set; }
|
public bool EnableAutoAdjustMainLvColWidth { get; set; }
|
||||||
@@ -104,9 +97,9 @@ public class UIItem
|
|||||||
public List<ColumnItem> MainColumnItem { get; set; }
|
public List<ColumnItem> MainColumnItem { get; set; }
|
||||||
public List<WindowSizeItem> WindowSizeItem { get; set; }
|
public List<WindowSizeItem> WindowSizeItem { get; set; }
|
||||||
public bool HideColumnIpInfo { get; set; }
|
public bool HideColumnIpInfo { get; set; }
|
||||||
|
public int? FrozenColumnCount { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
[Serializable]
|
|
||||||
public class ConstItem
|
public class ConstItem
|
||||||
{
|
{
|
||||||
public string? SubConvertUrl { get; set; }
|
public string? SubConvertUrl { get; set; }
|
||||||
@@ -115,7 +108,6 @@ public class ConstItem
|
|||||||
public string? RouteRulesTemplateSourceUrl { get; set; }
|
public string? RouteRulesTemplateSourceUrl { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
[Serializable]
|
|
||||||
public class KeyEventItem
|
public class KeyEventItem
|
||||||
{
|
{
|
||||||
public EGlobalHotkey EGlobalHotkey { get; set; }
|
public EGlobalHotkey EGlobalHotkey { get; set; }
|
||||||
@@ -129,7 +121,6 @@ public class KeyEventItem
|
|||||||
public int? KeyCode { get; set; }
|
public int? KeyCode { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
[Serializable]
|
|
||||||
public class CoreTypeItem
|
public class CoreTypeItem
|
||||||
{
|
{
|
||||||
public EConfigType ConfigType { get; set; }
|
public EConfigType ConfigType { get; set; }
|
||||||
@@ -137,7 +128,6 @@ public class CoreTypeItem
|
|||||||
public ECoreType CoreType { get; set; }
|
public ECoreType CoreType { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
[Serializable]
|
|
||||||
public class TunModeItem
|
public class TunModeItem
|
||||||
{
|
{
|
||||||
public bool EnableTun { get; set; }
|
public bool EnableTun { get; set; }
|
||||||
@@ -148,12 +138,12 @@ public class TunModeItem
|
|||||||
public bool EnableIPv6Address { get; set; }
|
public bool EnableIPv6Address { get; set; }
|
||||||
public string IcmpRouting { get; set; }
|
public string IcmpRouting { get; set; }
|
||||||
public bool EnableLegacyProtect { get; set; } = true;
|
public bool EnableLegacyProtect { get; set; } = true;
|
||||||
|
public string? DnsMode { get; set; }
|
||||||
public List<string>? RouteExcludeAddress { get; set; }
|
public List<string>? RouteExcludeAddress { get; set; }
|
||||||
public string IPv4Address { get; set; }
|
public string IPv4Address { get; set; }
|
||||||
public string IPv6Address { get; set; }
|
public string IPv6Address { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
[Serializable]
|
|
||||||
public class SpeedTestItem
|
public class SpeedTestItem
|
||||||
{
|
{
|
||||||
public int SpeedTestTimeout { get; set; }
|
public int SpeedTestTimeout { get; set; }
|
||||||
@@ -166,7 +156,6 @@ public class SpeedTestItem
|
|||||||
public int? SpeedTestDelayInterval { get; set; }
|
public int? SpeedTestDelayInterval { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
[Serializable]
|
|
||||||
public class RoutingBasicItem
|
public class RoutingBasicItem
|
||||||
{
|
{
|
||||||
public string DomainStrategy { get; set; }
|
public string DomainStrategy { get; set; }
|
||||||
@@ -174,7 +163,6 @@ public class RoutingBasicItem
|
|||||||
public string RoutingIndexId { get; set; }
|
public string RoutingIndexId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
[Serializable]
|
|
||||||
public class ColumnItem
|
public class ColumnItem
|
||||||
{
|
{
|
||||||
public string Name { get; set; }
|
public string Name { get; set; }
|
||||||
@@ -182,7 +170,6 @@ public class ColumnItem
|
|||||||
public int Index { get; set; }
|
public int Index { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
[Serializable]
|
|
||||||
public class Mux4RayItem
|
public class Mux4RayItem
|
||||||
{
|
{
|
||||||
public int? Concurrency { get; set; }
|
public int? Concurrency { get; set; }
|
||||||
@@ -190,7 +177,6 @@ public class Mux4RayItem
|
|||||||
public string? XudpProxyUDP443 { get; set; }
|
public string? XudpProxyUDP443 { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
[Serializable]
|
|
||||||
public class Mux4SboxItem
|
public class Mux4SboxItem
|
||||||
{
|
{
|
||||||
public string Protocol { get; set; }
|
public string Protocol { get; set; }
|
||||||
@@ -198,7 +184,6 @@ public class Mux4SboxItem
|
|||||||
public bool? Padding { get; set; }
|
public bool? Padding { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
[Serializable]
|
|
||||||
public class HysteriaItem
|
public class HysteriaItem
|
||||||
{
|
{
|
||||||
public int UpMbps { get; set; }
|
public int UpMbps { get; set; }
|
||||||
@@ -206,21 +191,18 @@ public class HysteriaItem
|
|||||||
public int HopInterval { get; set; } = Global.Hysteria2DefaultHopInt;
|
public int HopInterval { get; set; } = Global.Hysteria2DefaultHopInt;
|
||||||
}
|
}
|
||||||
|
|
||||||
[Serializable]
|
|
||||||
public class ClashUIItem
|
public class ClashUIItem
|
||||||
{
|
{
|
||||||
public ERuleMode RuleMode { get; set; }
|
|
||||||
public bool EnableIPv6 { get; set; }
|
public bool EnableIPv6 { get; set; }
|
||||||
public bool EnableMixinContent { get; set; }
|
public bool EnableMixinContent { get; set; }
|
||||||
public int ProxiesSorting { get; set; }
|
public int ProxiesSorting { get; set; }
|
||||||
public bool ProxiesAutoRefresh { get; set; }
|
public bool ProxiesAutoRefresh { get; set; }
|
||||||
public int ProxiesAutoDelayTestInterval { get; set; } = 10;
|
public int ProxiesRefreshInterval { get; set; } = 2;
|
||||||
public bool ConnectionsAutoRefresh { get; set; }
|
public bool ConnectionsAutoRefresh { get; set; }
|
||||||
public int ConnectionsRefreshInterval { get; set; } = 2;
|
public int ConnectionsRefreshInterval { get; set; } = 2;
|
||||||
public List<ColumnItem> ConnectionsColumnItem { get; set; }
|
public List<ColumnItem> ConnectionsColumnItem { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
[Serializable]
|
|
||||||
public class SystemProxyItem
|
public class SystemProxyItem
|
||||||
{
|
{
|
||||||
public ESysProxyType SysProxyType { get; set; }
|
public ESysProxyType SysProxyType { get; set; }
|
||||||
@@ -231,7 +213,6 @@ public class SystemProxyItem
|
|||||||
public string? CustomSystemProxyScriptPath { get; set; }
|
public string? CustomSystemProxyScriptPath { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
[Serializable]
|
|
||||||
public class WebDavItem
|
public class WebDavItem
|
||||||
{
|
{
|
||||||
public string? Url { get; set; }
|
public string? Url { get; set; }
|
||||||
@@ -240,14 +221,13 @@ public class WebDavItem
|
|||||||
public string? DirName { get; set; }
|
public string? DirName { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
[Serializable]
|
|
||||||
public class CheckUpdateItem
|
public class CheckUpdateItem
|
||||||
{
|
{
|
||||||
public bool CheckPreReleaseUpdate { get; set; }
|
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]
|
|
||||||
public class Fragment4RayItem
|
public class Fragment4RayItem
|
||||||
{
|
{
|
||||||
public string? Packets { get; set; }
|
public string? Packets { get; set; }
|
||||||
@@ -262,7 +242,6 @@ public class Fragment4RayItem
|
|||||||
// migration end
|
// migration end
|
||||||
}
|
}
|
||||||
|
|
||||||
[Serializable]
|
|
||||||
public class WindowSizeItem
|
public class WindowSizeItem
|
||||||
{
|
{
|
||||||
public string TypeName { get; set; }
|
public string TypeName { get; set; }
|
||||||
@@ -270,7 +249,6 @@ public class WindowSizeItem
|
|||||||
public int Height { get; set; }
|
public int Height { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
[Serializable]
|
|
||||||
public class SimpleDNSItem
|
public class SimpleDNSItem
|
||||||
{
|
{
|
||||||
public bool? UseSystemHosts { get; set; }
|
public bool? UseSystemHosts { get; set; }
|
||||||
@@ -278,6 +256,8 @@ public class SimpleDNSItem
|
|||||||
public bool? FakeIP { get; set; }
|
public bool? FakeIP { get; set; }
|
||||||
public bool? GlobalFakeIp { get; set; }
|
public bool? GlobalFakeIp { get; set; }
|
||||||
public string? FakeIPRange { get; set; }
|
public string? FakeIPRange { get; set; }
|
||||||
|
public bool? FakeIPv6 { get; set; }
|
||||||
|
public string? FakeIPv6Range { get; set; }
|
||||||
public bool? BlockBindingQuery { get; set; }
|
public bool? BlockBindingQuery { get; set; }
|
||||||
public bool? BlockAAAAQuery { get; set; }
|
public bool? BlockAAAAQuery { get; set; }
|
||||||
public string? DirectDNS { get; set; }
|
public string? DirectDNS { get; set; }
|
||||||
@@ -293,7 +273,6 @@ public class SimpleDNSItem
|
|||||||
public bool? EnableHappyEyeballs { get; set; }
|
public bool? EnableHappyEyeballs { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
[Serializable]
|
|
||||||
public class HappyEyeballs4RayItem
|
public class HappyEyeballs4RayItem
|
||||||
{
|
{
|
||||||
public int? TryDelayMs { get; set; }
|
public int? TryDelayMs { get; set; }
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ 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
|
||||||
|
// tunnel; only a positive detection of the host having no global IPv6 address turns it off.
|
||||||
|
public bool HasGlobalIPv6Address { get; init; } = true;
|
||||||
|
|
||||||
// Generation Context
|
// Generation Context
|
||||||
public Dictionary<object, string> CustomOutboundMap { get; init; } = new();
|
public Dictionary<object, string> CustomOutboundMap { get; init; } = new();
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
namespace ServiceLib.Models.CoreConfigs;
|
namespace ServiceLib.Models.CoreConfigs;
|
||||||
|
|
||||||
[Serializable]
|
|
||||||
public class CoreInfo
|
public class CoreInfo
|
||||||
{
|
{
|
||||||
public ECoreType CoreType { get; set; }
|
public ECoreType CoreType { get; set; }
|
||||||
@@ -16,6 +15,7 @@ public class CoreInfo
|
|||||||
public string? DownloadUrlLinuxLoong64 { get; set; }
|
public string? DownloadUrlLinuxLoong64 { get; set; }
|
||||||
public string? DownloadUrlOSX64 { get; set; }
|
public string? DownloadUrlOSX64 { get; set; }
|
||||||
public string? DownloadUrlOSXArm64 { get; set; }
|
public string? DownloadUrlOSXArm64 { get; set; }
|
||||||
|
public SemanticVersion? LockedMaxVersion { get; set; }
|
||||||
public string? Match { get; set; }
|
public string? Match { get; set; }
|
||||||
public string? VersionArg { get; set; }
|
public string? VersionArg { get; set; }
|
||||||
public bool AbsolutePath { get; set; }
|
public bool AbsolutePath { get; set; }
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ public class SingboxConfig
|
|||||||
public List<Outbound4Sbox> outbounds { get; set; }
|
public List<Outbound4Sbox> outbounds { get; set; }
|
||||||
public List<Endpoints4Sbox>? endpoints { get; set; }
|
public List<Endpoints4Sbox>? endpoints { get; set; }
|
||||||
public Route4Sbox route { get; set; }
|
public Route4Sbox route { get; set; }
|
||||||
|
public List<HttpClient4Sbox>? http_clients { get; set; }
|
||||||
public Experimental4Sbox? experimental { get; set; }
|
public Experimental4Sbox? experimental { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,6 +25,7 @@ public class Dns4Sbox
|
|||||||
public List<Server4Sbox> servers { get; set; }
|
public List<Server4Sbox> servers { get; set; }
|
||||||
public List<Rule4Sbox> rules { get; set; }
|
public List<Rule4Sbox> rules { get; set; }
|
||||||
public string? final { get; set; }
|
public string? final { get; set; }
|
||||||
|
public bool? optimistic { get; set; }
|
||||||
public string? strategy { get; set; }
|
public string? strategy { get; set; }
|
||||||
public bool? disable_cache { get; set; }
|
public bool? disable_cache { get; set; }
|
||||||
public bool? disable_expire { get; set; }
|
public bool? disable_expire { get; set; }
|
||||||
@@ -42,7 +44,6 @@ public class Route4Sbox
|
|||||||
public string? final { get; set; }
|
public string? final { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
[Serializable]
|
|
||||||
public class Rule4Sbox
|
public class Rule4Sbox
|
||||||
{
|
{
|
||||||
public string? outbound { get; set; }
|
public string? outbound { get; set; }
|
||||||
@@ -94,9 +95,13 @@ public class Rule4Sbox
|
|||||||
public bool? rule_set_ip_cidr_match_source { get; set; }
|
public bool? rule_set_ip_cidr_match_source { get; set; }
|
||||||
public bool? rule_set_ip_cidr_accept_empty { get; set; }
|
public bool? rule_set_ip_cidr_accept_empty { get; set; }
|
||||||
public bool? tls_record_fragment { get; set; }
|
public bool? tls_record_fragment { get; set; }
|
||||||
|
public string? preferred_by { get; set; }
|
||||||
|
public string? match_response { get; set; } // or bool
|
||||||
|
public string? tag { get; set; }
|
||||||
|
public bool? race { get; set; }
|
||||||
|
public bool? speculative { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
[Serializable]
|
|
||||||
public class Inbound4Sbox
|
public class Inbound4Sbox
|
||||||
{
|
{
|
||||||
public string type { get; set; }
|
public string type { get; set; }
|
||||||
@@ -108,8 +113,8 @@ public class Inbound4Sbox
|
|||||||
public int? mtu { get; set; }
|
public int? mtu { get; set; }
|
||||||
public bool? auto_route { get; set; }
|
public bool? auto_route { get; set; }
|
||||||
public bool? strict_route { get; set; }
|
public bool? strict_route { get; set; }
|
||||||
public bool? endpoint_independent_nat { get; set; }
|
|
||||||
public string? stack { get; set; }
|
public string? stack { get; set; }
|
||||||
|
public string? dns_mode { get; set; }
|
||||||
public List<User4Sbox> users { get; set; }
|
public List<User4Sbox> users { get; set; }
|
||||||
public List<string>? route_exclude_address { get; set; }
|
public List<string>? route_exclude_address { get; set; }
|
||||||
}
|
}
|
||||||
@@ -122,8 +127,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; }
|
||||||
@@ -165,6 +168,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
|
||||||
@@ -224,7 +231,7 @@ public class Reality4Sbox
|
|||||||
public class Transport4Sbox
|
public class Transport4Sbox
|
||||||
{
|
{
|
||||||
public string? type { get; set; }
|
public string? type { get; set; }
|
||||||
public object? host { get; set; }
|
public JsonNode? host { get; set; }
|
||||||
public string? path { get; set; }
|
public string? path { get; set; }
|
||||||
public Headers4Sbox? headers { get; set; }
|
public Headers4Sbox? headers { get; set; }
|
||||||
|
|
||||||
@@ -265,10 +272,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; }
|
||||||
|
|
||||||
@@ -318,10 +323,12 @@ public class Ruleset4Sbox
|
|||||||
public string? format { get; set; }
|
public string? format { get; set; }
|
||||||
public string? path { get; set; }
|
public string? path { get; set; }
|
||||||
public string? url { get; set; }
|
public string? url { get; set; }
|
||||||
public string? download_detour { get; set; }
|
public string? http_client { get; set; }
|
||||||
public string? update_interval { get; set; }
|
public string? update_interval { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class HttpClient4Sbox : BaseServer4Sbox;
|
||||||
|
|
||||||
public abstract class DialFields4Sbox
|
public abstract class DialFields4Sbox
|
||||||
{
|
{
|
||||||
public string? detour { get; set; }
|
public string? detour { get; set; }
|
||||||
@@ -351,4 +358,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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ namespace ServiceLib.Models.CoreConfigs;
|
|||||||
public class V2rayConfig
|
public class V2rayConfig
|
||||||
{
|
{
|
||||||
public Log4Ray log { get; set; }
|
public Log4Ray log { get; set; }
|
||||||
public object dns { get; set; }
|
public JsonObject? dns { get; set; }
|
||||||
public FakeDns4Ray? fakedns { get; set; }
|
public JsonNode? fakedns { get; set; }
|
||||||
public List<Inbounds4Ray> inbounds { get; set; }
|
public List<Inbounds4Ray> inbounds { get; set; }
|
||||||
public List<Outbounds4Ray> outbounds { get; set; }
|
public List<Outbounds4Ray> outbounds { get; set; }
|
||||||
public Routing4Ray routing { get; set; }
|
public Routing4Ray routing { get; set; }
|
||||||
@@ -75,8 +75,6 @@ public class Inboundsettings4Ray
|
|||||||
|
|
||||||
public string? address { get; set; }
|
public string? address { get; set; }
|
||||||
|
|
||||||
public List<UsersItem4Ray>? clients { get; set; }
|
|
||||||
|
|
||||||
public string? decryption { get; set; }
|
public string? decryption { get; set; }
|
||||||
|
|
||||||
public bool? allowTransparent { get; set; }
|
public bool? allowTransparent { get; set; }
|
||||||
@@ -94,21 +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 class UsersItem4Ray
|
public bool? autoSystemDnsToGateway { get; set; }
|
||||||
{
|
|
||||||
public string? id { get; set; }
|
|
||||||
|
|
||||||
public int? alterId { get; set; }
|
public List<string>? autoSystemWfpBlockLeak { get; set; }
|
||||||
|
|
||||||
public string? email { get; set; }
|
|
||||||
|
|
||||||
public string? security { get; set; }
|
|
||||||
|
|
||||||
public string? encryption { get; set; }
|
|
||||||
|
|
||||||
public string? flow { get; set; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public class Sniffing4Ray
|
public class Sniffing4Ray
|
||||||
@@ -137,17 +124,13 @@ public class Outbounds4Ray
|
|||||||
|
|
||||||
public class Outboundsettings4Ray
|
public class Outboundsettings4Ray
|
||||||
{
|
{
|
||||||
public List<VnextItem4Ray>? vnext { get; set; }
|
|
||||||
|
|
||||||
public List<ServersItem4Ray>? servers { get; set; }
|
|
||||||
|
|
||||||
public Response4Ray? response { get; set; }
|
public Response4Ray? response { get; set; }
|
||||||
|
|
||||||
public int? userLevel { get; set; }
|
public int? userLevel { get; set; }
|
||||||
|
|
||||||
public string? secretKey { get; set; }
|
public string? secretKey { get; set; }
|
||||||
|
|
||||||
public object? address { get; set; }
|
public JsonNode? address { get; set; }
|
||||||
|
|
||||||
public int? port { get; set; }
|
public int? port { get; set; }
|
||||||
|
|
||||||
@@ -159,7 +142,7 @@ public class Outboundsettings4Ray
|
|||||||
|
|
||||||
public string? email { get; set; }
|
public string? email { get; set; }
|
||||||
|
|
||||||
public object? headers { get; set; }
|
public JsonNode? headers { get; set; }
|
||||||
|
|
||||||
public List<WireguardPeer4Ray>? peers { get; set; }
|
public List<WireguardPeer4Ray>? peers { get; set; }
|
||||||
|
|
||||||
@@ -174,29 +157,16 @@ public class Outboundsettings4Ray
|
|||||||
public int? version { get; set; }
|
public int? version { get; set; }
|
||||||
|
|
||||||
public List<string>? remoteDNS { get; set; }
|
public List<string>? remoteDNS { get; set; }
|
||||||
}
|
|
||||||
|
|
||||||
public class WireguardPeer4Ray
|
public string? id { get; set; }
|
||||||
{
|
|
||||||
public string endpoint { get; set; }
|
|
||||||
public string publicKey { get; set; }
|
|
||||||
public string? preSharedKey { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public class VnextItem4Ray
|
public int? alterId { get; set; }
|
||||||
{
|
|
||||||
public string address { get; set; }
|
|
||||||
|
|
||||||
public int port { get; set; }
|
public string? security { get; set; }
|
||||||
|
|
||||||
public List<UsersItem4Ray> users { get; set; }
|
public string? encryption { get; set; }
|
||||||
}
|
|
||||||
|
|
||||||
public class ServersItem4Ray
|
public string? flow { get; set; }
|
||||||
{
|
|
||||||
public string email { get; set; }
|
|
||||||
|
|
||||||
public string address { get; set; }
|
|
||||||
|
|
||||||
public string? method { get; set; }
|
public string? method { get; set; }
|
||||||
|
|
||||||
@@ -204,24 +174,14 @@ public class ServersItem4Ray
|
|||||||
|
|
||||||
public string? password { get; set; }
|
public string? password { get; set; }
|
||||||
|
|
||||||
public int port { get; set; }
|
|
||||||
|
|
||||||
public int? level { get; set; }
|
|
||||||
|
|
||||||
public string flow { get; set; }
|
|
||||||
|
|
||||||
public bool? uot { get; set; }
|
public bool? uot { get; set; }
|
||||||
|
|
||||||
public List<SocksUsersItem4Ray> users { get; set; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public class SocksUsersItem4Ray
|
public class WireguardPeer4Ray
|
||||||
{
|
{
|
||||||
public string user { get; set; }
|
public string endpoint { get; set; }
|
||||||
|
public string publicKey { get; set; }
|
||||||
public string pass { get; set; }
|
public string? preSharedKey { get; set; }
|
||||||
|
|
||||||
public int? level { get; set; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public class Mux4Ray
|
public class Mux4Ray
|
||||||
@@ -239,8 +199,8 @@ public class Response4Ray
|
|||||||
|
|
||||||
public class Dns4Ray
|
public class Dns4Ray
|
||||||
{
|
{
|
||||||
public Dictionary<string, object>? hosts { get; set; }
|
public Dictionary<string, JsonNode>? hosts { get; set; }
|
||||||
public List<object> servers { get; set; }
|
public List<JsonNode> servers { get; set; }
|
||||||
public bool? serveStale { get; set; }
|
public bool? serveStale { get; set; }
|
||||||
public bool? enableParallelQuery { get; set; }
|
public bool? enableParallelQuery { get; set; }
|
||||||
public string? queryStrategy { get; set; }
|
public string? queryStrategy { get; set; }
|
||||||
@@ -254,6 +214,7 @@ public class DnsServer4Ray
|
|||||||
public List<string>? domains { get; set; }
|
public List<string>? domains { get; set; }
|
||||||
public bool? skipFallback { get; set; }
|
public bool? skipFallback { get; set; }
|
||||||
public List<string>? expectedIPs { get; set; }
|
public List<string>? expectedIPs { get; set; }
|
||||||
|
public bool? finalQuery { get; set; }
|
||||||
public string? tag { get; set; }
|
public string? tag { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,7 +227,6 @@ public class Routing4Ray
|
|||||||
public List<BalancersItem4Ray>? balancers { get; set; }
|
public List<BalancersItem4Ray>? balancers { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
[Serializable]
|
|
||||||
public class RulesItem4Ray
|
public class RulesItem4Ray
|
||||||
{
|
{
|
||||||
public string? type { get; set; }
|
public string? type { get; set; }
|
||||||
@@ -360,7 +320,10 @@ public class StreamSettings4Ray
|
|||||||
|
|
||||||
public HysteriaSettings4Ray? hysteriaSettings { get; set; }
|
public HysteriaSettings4Ray? hysteriaSettings { get; set; }
|
||||||
|
|
||||||
public object? finalmask { get; set; }
|
public MasqueSettings4Ray? masqueSettings { get; set; }
|
||||||
|
public XdriveSettings4Ray? xdriveSettings { get; set; }
|
||||||
|
|
||||||
|
public JsonObject? finalmask { get; set; }
|
||||||
|
|
||||||
public Sockopt4Ray? sockopt { get; set; }
|
public Sockopt4Ray? sockopt { get; set; }
|
||||||
}
|
}
|
||||||
@@ -383,7 +346,6 @@ public class TlsSettings4Ray
|
|||||||
public string? pinnedPeerCertSha256 { get; set; }
|
public string? pinnedPeerCertSha256 { get; set; }
|
||||||
public bool? disableSystemRoot { get; set; }
|
public bool? disableSystemRoot { get; set; }
|
||||||
public string? echConfigList { get; set; }
|
public string? echConfigList { get; set; }
|
||||||
public string? echForceQuery { get; set; }
|
|
||||||
public Sockopt4Ray? echSockopt { get; set; }
|
public Sockopt4Ray? echSockopt { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -402,9 +364,9 @@ public class Header4Ray
|
|||||||
{
|
{
|
||||||
public string type { get; set; }
|
public string type { get; set; }
|
||||||
|
|
||||||
public object request { get; set; }
|
public JsonObject request { get; set; }
|
||||||
|
|
||||||
public object response { get; set; }
|
public JsonObject response { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class KcpSettings4Ray
|
public class KcpSettings4Ray
|
||||||
@@ -450,7 +412,7 @@ public class XhttpSettings4Ray
|
|||||||
public string? path { get; set; }
|
public string? path { get; set; }
|
||||||
public string? host { get; set; }
|
public string? host { get; set; }
|
||||||
public string? mode { get; set; }
|
public string? mode { get; set; }
|
||||||
public object? extra { get; set; }
|
public JsonObject? extra { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class HttpSettings4Ray
|
public class HttpSettings4Ray
|
||||||
@@ -487,10 +449,40 @@ public class HysteriaSettings4Ray
|
|||||||
public string? auth { get; set; }
|
public string? auth { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class UdpHop4Ray
|
public class MasqueSettings4Ray
|
||||||
{
|
{
|
||||||
public string? ports { get; set; }
|
public string? path { get; set; }
|
||||||
public string? interval { get; set; }
|
public Dictionary<string, string>? headers { get; set; }
|
||||||
|
public MasqueWarpSettings4Ray? warp { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class MasqueWarpSettings4Ray
|
||||||
|
{
|
||||||
|
public string? privateKey { get; set; }
|
||||||
|
public string? publicKey { get; set; }
|
||||||
|
public List<string>? address { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class XdriveSettings4Ray
|
||||||
|
{
|
||||||
|
public string? service { get; set; }
|
||||||
|
public string? remoteFolder { get; set; }
|
||||||
|
public List<string>? secrets { get; set; }
|
||||||
|
public S3Settings4Ray? s3 { get; set; }
|
||||||
|
public int? segmentBytes { get; set; }
|
||||||
|
public int? flushIntervalMs { get; set; }
|
||||||
|
public int? pollIntervalMs { get; set; }
|
||||||
|
public int? maxPollIntervalMs { get; set; }
|
||||||
|
public int? eagerWindowMs { get; set; }
|
||||||
|
public int? concurrency { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class S3Settings4Ray
|
||||||
|
{
|
||||||
|
public string? endpoint { get; set; }
|
||||||
|
public string? region { get; set; }
|
||||||
|
public string? bucket { get; set; }
|
||||||
|
public bool? pathStyle { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class Finalmask4Ray
|
public class Finalmask4Ray
|
||||||
@@ -517,6 +509,12 @@ public class MaskSettings4Ray
|
|||||||
public List<string>? stunServers { get; set; }
|
public List<string>? stunServers { get; set; }
|
||||||
public string? packetSize { get; set; }
|
public string? packetSize { get; set; }
|
||||||
|
|
||||||
|
// udphop
|
||||||
|
public string? mode { get; set; }
|
||||||
|
public string? interval { get; set; }
|
||||||
|
// public List<string>? remoteIPs { get; set; }
|
||||||
|
public string? remotePorts { get; set; }
|
||||||
|
|
||||||
// fragment
|
// fragment
|
||||||
public string? packets { get; set; }
|
public string? packets { get; set; }
|
||||||
|
|
||||||
@@ -543,7 +541,6 @@ public class QuicParams4Ray
|
|||||||
public string? congestion { get; set; }
|
public string? congestion { get; set; }
|
||||||
public string? brutalUp { get; set; }
|
public string? brutalUp { get; set; }
|
||||||
public string? brutalDown { get; set; }
|
public string? brutalDown { get; set; }
|
||||||
public UdpHop4Ray? udpHop { get; set; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public class AccountsItem4Ray
|
public class AccountsItem4Ray
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace ServiceLib.Models.Dto;
|
||||||
|
|
||||||
|
public sealed record AvailabilityCheckResult(int Time, string Ip)
|
||||||
|
{
|
||||||
|
public string? GetValidIp() => Ip == Global.None ? null : Ip;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user