mirror of
https://github.com/2dust/v2rayN.git
synced 2026-09-27 00:52:07 +03:00
Compare commits
146 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 521230c40d | |||
| 426d5d5a21 | |||
| 3f14c86a04 | |||
| 70b8ecd3c8 | |||
| 0d452328a2 | |||
| 2a65e5ac80 | |||
| f332caf507 | |||
| 4b412e246f | |||
| 09ac4d181a | |||
| c575527725 | |||
| 589ccc6e06 | |||
| 0a2f3e4f22 | |||
| 007adb6403 | |||
| e50c0a9170 | |||
| af0eb9ed14 | |||
| 0436081436 | |||
| 2a89a2e597 | |||
| 2be63d1655 | |||
| 74bd28bbd0 | |||
| be501cbd2a | |||
| e20b4dbfab | |||
| 728cbe70f2 | |||
| 26a86f9bd2 | |||
| c66c55b05e | |||
| ebb4bd5daa | |||
| 14e27f3d23 | |||
| 2d514ff480 | |||
| 11bd52b6a5 | |||
| 3b3fe8def3 | |||
| c9e843aa3b | |||
| 7be264c65c | |||
| 5e6660aeee | |||
| 5b27f4836f | |||
| e0ad2c46e6 | |||
| 230a2f6773 | |||
| e7cc4c832e | |||
| fb7033aab5 | |||
| b6cc3913ac | |||
| bc06a5dc21 | |||
| 9c1951178c | |||
| b3df42b9ba | |||
| e01717d832 | |||
| 8b4063b44b | |||
| 3136f573dd | |||
| e101b1d7b0 | |||
| 31044f449d | |||
| eacf8b0784 | |||
| f347fe1c03 | |||
| 71208eb711 | |||
| 339eaa6bfd | |||
| 717ef7f0a7 | |||
| 4a9d83f9dc | |||
| 1ac14d0903 | |||
| 08d423e1b4 | |||
| 9638b4c368 | |||
| 1034ce6e02 | |||
| 5efff37acb | |||
| e8cdd1cc02 | |||
| d381d429e4 | |||
| b27ae11d1e | |||
| eff584597f | |||
| ccf18ba0fb | |||
| 15ba2bdf93 | |||
| e1e6c5ddb0 | |||
| d924c6f557 | |||
| 635a04d74c | |||
| 2e1bb23da7 | |||
| 6f3bacd5b4 | |||
| 074ce5de04 | |||
| 02df430172 | |||
| 611ce0fd2c | |||
| a846e74e8f | |||
| 92c3df45ae | |||
| ee7e21268a | |||
| dc216c2b02 | |||
| beffa919d3 | |||
| a654826231 | |||
| b01476d147 | |||
| cf8dd45abe | |||
| 9bba6f53f8 | |||
| cd77f1d882 | |||
| 0427037638 | |||
| e749b81ecf | |||
| fd2c942231 | |||
| fa42eb670f | |||
| 0d42996573 | |||
| a37772f12b | |||
| 531e0e9443 | |||
| 984c77f0c4 | |||
| b5e0be47a5 | |||
| 6df0e6de1b | |||
| 2b5328665f | |||
| 4584ecc5c9 | |||
| d9dc2d7cf5 | |||
| 36f8565b7a | |||
| f55d8b2565 | |||
| 09ea4890a7 | |||
| 5cc2aaba13 | |||
| b8889bad86 |
@@ -6,6 +6,6 @@ updates:
|
|||||||
interval: "daily"
|
interval: "daily"
|
||||||
|
|
||||||
- package-ecosystem: "nuget"
|
- package-ecosystem: "nuget"
|
||||||
directory: "/"
|
directory: "/v2rayN"
|
||||||
schedule:
|
schedule:
|
||||||
interval: "daily"
|
interval: "daily"
|
||||||
|
|||||||
@@ -1,3 +1,49 @@
|
|||||||
|
/*
|
||||||
|
==============================================================================
|
||||||
|
THIRD-PARTY CA CERTIFICATE DATA ATTRIBUTION
|
||||||
|
==============================================================================
|
||||||
|
|
||||||
|
This component includes CA certificate data obtained from third-party sources.
|
||||||
|
|
||||||
|
1. Common CA Database (CCADB)
|
||||||
|
------------------------------------------------------------------------------
|
||||||
|
Website:
|
||||||
|
https://www.ccadb.org/
|
||||||
|
|
||||||
|
Data source:
|
||||||
|
https://ccadb.my.salesforce-sites.com/mozilla/IncludedRootsPEMTxt?TrustBitsInclude=Websites
|
||||||
|
|
||||||
|
License:
|
||||||
|
Community Data License Agreement – Permissive, Version 2.0 (CDLA-2.0 Permissive)
|
||||||
|
|
||||||
|
The names and trademarks of CCADB contributors may not be used to endorse or
|
||||||
|
promote products derived from this data without prior written permission.
|
||||||
|
|
||||||
|
License text:
|
||||||
|
https://cdla.dev/permissive-2-0/
|
||||||
|
|
||||||
|
|
||||||
|
2. Chromium Root Store
|
||||||
|
------------------------------------------------------------------------------
|
||||||
|
Source:
|
||||||
|
Chromium Project
|
||||||
|
|
||||||
|
Data source:
|
||||||
|
https://chromium.googlesource.com/chromium/src/+/main/net/data/ssl/chrome_root_store/root_store.certs?format=TEXT
|
||||||
|
|
||||||
|
License:
|
||||||
|
BSD 3-Clause License
|
||||||
|
|
||||||
|
Copyright:
|
||||||
|
Copyright (c) The Chromium Authors
|
||||||
|
|
||||||
|
The Chromium Root Store data is distributed under the BSD 3-Clause License.
|
||||||
|
|
||||||
|
License text:
|
||||||
|
https://opensource.org/licenses/BSD-3-Clause
|
||||||
|
==============================================================================
|
||||||
|
*/
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
@@ -27,68 +73,23 @@ Console.WriteLine("\nAll done!");
|
|||||||
|
|
||||||
async Task ProcessMozillaAsync(string outputDir)
|
async Task ProcessMozillaAsync(string outputDir)
|
||||||
{
|
{
|
||||||
const string certdataUrl = "https://raw.githubusercontent.com/mozilla-firefox/firefox/refs/heads/release/security/nss/lib/ckfw/builtins/certdata.txt";
|
const string certdataUrl = "https://ccadb.my.salesforce-sites.com/mozilla/IncludedRootsPEMTxt?TrustBitsInclude=Websites";
|
||||||
var outputFile = Path.Combine(outputDir, "mozilla_roots_pem");
|
var outputFile = Path.Combine(outputDir, "mozilla_roots_pem");
|
||||||
|
|
||||||
Console.WriteLine("Downloading Mozilla certdata.txt...");
|
Console.WriteLine("Downloading Mozilla certdata.txt...");
|
||||||
var content = await client.GetStringAsync(certdataUrl);
|
var content = await client.GetStringAsync(certdataUrl);
|
||||||
|
|
||||||
Console.WriteLine("Parsing MULTILINE_OCTAL to PEM...");
|
var pemMatches = Regex.Matches(content, @"(-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----)");
|
||||||
var pems = ParseMozillaCertData(content);
|
var pems = new List<string>();
|
||||||
|
foreach (Match m in pemMatches)
|
||||||
|
{
|
||||||
|
pems.Add(m.Groups[1].Value.Trim());
|
||||||
|
}
|
||||||
|
|
||||||
await File.WriteAllTextAsync(outputFile, string.Join("\n\n", pems));
|
await File.WriteAllTextAsync(outputFile, string.Join("\n\n", pems));
|
||||||
Console.WriteLine($"Mozilla roots saved to: {outputFile} ({pems.Count} certificates)");
|
Console.WriteLine($"Mozilla roots saved to: {outputFile} ({pems.Count} certificates)");
|
||||||
}
|
}
|
||||||
|
|
||||||
List<string> ParseMozillaCertData(string text)
|
|
||||||
{
|
|
||||||
var pems = new List<string>();
|
|
||||||
|
|
||||||
var matches = Regex.Matches(text,
|
|
||||||
@"CKA_VALUE MULTILINE_OCTAL\s*([\s\S]*?)\s*END",
|
|
||||||
RegexOptions.Multiline);
|
|
||||||
|
|
||||||
foreach (Match m in matches)
|
|
||||||
{
|
|
||||||
var octalBlock = m.Groups[1].Value.Trim();
|
|
||||||
var der = OctalToBytes(octalBlock);
|
|
||||||
if (der.Length > 0)
|
|
||||||
{
|
|
||||||
var pem = ConvertToPem(der);
|
|
||||||
pems.Add(pem);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return pems;
|
|
||||||
}
|
|
||||||
|
|
||||||
byte[] OctalToBytes(string octalBlock)
|
|
||||||
{
|
|
||||||
var bytes = new List<byte>();
|
|
||||||
var matches = Regex.Matches(octalBlock, @"\\(\d{1,3})");
|
|
||||||
foreach (Match m in matches)
|
|
||||||
{
|
|
||||||
bytes.Add((byte)Convert.ToInt32(m.Groups[1].Value, 8));
|
|
||||||
}
|
|
||||||
return bytes.ToArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
string ConvertToPem(byte[] der)
|
|
||||||
{
|
|
||||||
var base64 = Convert.ToBase64String(der);
|
|
||||||
var sb = new StringBuilder();
|
|
||||||
sb.Append("-----BEGIN CERTIFICATE-----\n");
|
|
||||||
|
|
||||||
for (int i = 0; i < base64.Length; i += 64)
|
|
||||||
{
|
|
||||||
var length = Math.Min(64, base64.Length - i);
|
|
||||||
sb.Append(base64.Substring(i, length));
|
|
||||||
sb.Append("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
sb.Append("-----END CERTIFICATE-----\n");
|
|
||||||
return sb.ToString().TrimEnd();
|
|
||||||
}
|
|
||||||
|
|
||||||
async Task ProcessChromeAsync(string outputDir)
|
async Task ProcessChromeAsync(string outputDir)
|
||||||
{
|
{
|
||||||
const string chromeCertsUrl = "https://chromium.googlesource.com/chromium/src/+/main/net/data/ssl/chrome_root_store/root_store.certs?format=TEXT";
|
const string chromeCertsUrl = "https://chromium.googlesource.com/chromium/src/+/main/net/data/ssl/chrome_root_store/root_store.certs?format=TEXT";
|
||||||
|
|||||||
@@ -205,7 +205,7 @@ jobs:
|
|||||||
deb-loong64:
|
deb-loong64:
|
||||||
name: build and release deb loong64
|
name: build and release deb 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-arm
|
||||||
env:
|
env:
|
||||||
RELEASE_TAG: ${{ case(inputs.release_tag != '', inputs.release_tag, github.ref_name) }}
|
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
|
QCOW2_URL: https://github.com/xujiegb/debian-loong64-qcow2/releases/download/13.5/debian13-loong64.qcow2
|
||||||
@@ -234,10 +234,10 @@ jobs:
|
|||||||
- name: Download QEMU prebuild
|
- name: Download QEMU prebuild
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
wget -O qemu-linux-x64-v3.tar.gz "https://github.com/xujiegb/qemu-linux-prebuild/releases/download/${QEMU_VERSION}/qemu-linux-x64-v3.tar.gz"
|
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-x64-v3.tar.gz
|
tar -xzf qemu-linux-arm64-v8.2.tar.gz
|
||||||
mkdir -p "$HOME/qemu-install"
|
mkdir -p "$HOME/qemu-install"
|
||||||
rsync -a qemu-linux-x64-v3/ "$HOME/qemu-install/"
|
rsync -a qemu-linux-arm64-v8.2/ "$HOME/qemu-install/"
|
||||||
"$HOME/qemu-install/bin/qemu-system-loongarch64" --version
|
"$HOME/qemu-install/bin/qemu-system-loongarch64" --version
|
||||||
- name: Download loong64 qcow2 and EFI firmware
|
- name: Download loong64 qcow2 and EFI firmware
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -355,7 +355,7 @@ jobs:
|
|||||||
rpm-loong64:
|
rpm-loong64:
|
||||||
name: build and release 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/'))
|
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-arm
|
||||||
env:
|
env:
|
||||||
RELEASE_TAG: ${{ case(inputs.release_tag != '', inputs.release_tag, github.ref_name) }}
|
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
|
QCOW2_URL: https://github.com/xujiegb/fedora-loong64-qcow2/releases/download/43/fedora43-loong64.qcow2
|
||||||
@@ -384,10 +384,10 @@ jobs:
|
|||||||
- name: Download QEMU prebuild
|
- name: Download QEMU prebuild
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
wget -O qemu-linux-x64-v3.tar.gz "https://github.com/xujiegb/qemu-linux-prebuild/releases/download/${QEMU_VERSION}/qemu-linux-x64-v3.tar.gz"
|
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-x64-v3.tar.gz
|
tar -xzf qemu-linux-arm64-v8.2.tar.gz
|
||||||
mkdir -p "$HOME/qemu-install"
|
mkdir -p "$HOME/qemu-install"
|
||||||
rsync -a qemu-linux-x64-v3/ "$HOME/qemu-install/"
|
rsync -a qemu-linux-arm64-v8.2/ "$HOME/qemu-install/"
|
||||||
"$HOME/qemu-install/bin/qemu-system-loongarch64" --version
|
"$HOME/qemu-install/bin/qemu-system-loongarch64" --version
|
||||||
- name: Download loong64 qcow2 and EFI firmware
|
- name: Download loong64 qcow2 and EFI firmware
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ jobs:
|
|||||||
dotnet --list-sdks 2>$null; $LASTEXITCODE=0
|
dotnet --list-sdks 2>$null; $LASTEXITCODE=0
|
||||||
|
|
||||||
- name: Setup .NET 10.0.1xx
|
- name: Setup .NET 10.0.1xx
|
||||||
uses: actions/setup-dotnet@v5.4.0
|
uses: actions/setup-dotnet@v6
|
||||||
with:
|
with:
|
||||||
dotnet-version: 10.0.1xx
|
dotnet-version: 10.0.1xx
|
||||||
|
|
||||||
@@ -146,7 +146,7 @@ jobs:
|
|||||||
$xrayTag = "v$xrayVer"
|
$xrayTag = "v$xrayVer"
|
||||||
$singTag = "v$singVer"
|
$singTag = "v$singVer"
|
||||||
|
|
||||||
$xrayUrl = "https://github.com/autorepobot/Xray-core/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"
|
||||||
|
|||||||
@@ -69,9 +69,9 @@ jobs:
|
|||||||
dotnet --list-sdks 2>$null; $LASTEXITCODE=0
|
dotnet --list-sdks 2>$null; $LASTEXITCODE=0
|
||||||
|
|
||||||
- name: Setup .NET
|
- name: Setup .NET
|
||||||
uses: actions/setup-dotnet@v5.4.0
|
uses: actions/setup-dotnet@v6
|
||||||
with:
|
with:
|
||||||
dotnet-version: '10.0.1xx'
|
dotnet-version: 10.0.1xx
|
||||||
|
|
||||||
- name: Build v2rayN
|
- name: Build v2rayN
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -89,7 +89,7 @@ jobs:
|
|||||||
find ${{ matrix.arch }} -type f -name '*.pdb' -delete
|
find ${{ matrix.arch }} -type f -name '*.pdb' -delete
|
||||||
|
|
||||||
- name: Upload build artifacts
|
- name: Upload build artifacts
|
||||||
uses: actions/upload-artifact@v7.0.1
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: ${{ matrix.arch }}
|
name: ${{ matrix.arch }}
|
||||||
path: ${{ matrix.arch }}
|
path: ${{ matrix.arch }}
|
||||||
|
|||||||
@@ -1,29 +1,52 @@
|
|||||||
name: Code Test
|
name: Code Test
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
paths:
|
||||||
|
- 'v2rayN/ServiceLib/**'
|
||||||
|
- 'v2rayN/ServiceLib.UdpTest/**'
|
||||||
|
- 'v2rayN/ServiceLib.Tests/**'
|
||||||
|
- 'v2rayN/Directory.Build.*'
|
||||||
|
- 'v2rayN/Directory.Packages.props'
|
||||||
|
- 'global.json'
|
||||||
|
- '.github/workflows/test.yml'
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
paths:
|
paths:
|
||||||
- 'v2rayN/ServiceLib/Services/CoreConfig/**'
|
- 'v2rayN/ServiceLib/**'
|
||||||
- 'v2rayN/ServiceLib/Handler/Fmt/**'
|
- 'v2rayN/ServiceLib.UdpTest/**'
|
||||||
|
- 'v2rayN/ServiceLib.Tests/**'
|
||||||
|
- 'v2rayN/Directory.Build.*'
|
||||||
|
- 'v2rayN/Directory.Packages.props'
|
||||||
|
- 'global.json'
|
||||||
- '.github/workflows/test.yml'
|
- '.github/workflows/test.yml'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
checks: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
with:
|
|
||||||
submodules: 'recursive'
|
|
||||||
fetch-depth: '0'
|
|
||||||
|
|
||||||
- name: Setup .NET
|
- name: Setup .NET
|
||||||
uses: actions/setup-dotnet@v5.4.0
|
uses: actions/setup-dotnet@v6
|
||||||
with:
|
with:
|
||||||
dotnet-version: '8.0.x'
|
dotnet-version: '10.x'
|
||||||
|
dotnet-quality: 'ga'
|
||||||
|
|
||||||
- name: Test Code
|
- name: Test Code
|
||||||
working-directory: ./v2rayN
|
run: dotnet test --project ./v2rayN/ServiceLib.Tests -c Release --results-directory ./TestResults -- --report-trx
|
||||||
run: dotnet test ./ServiceLib.Tests
|
|
||||||
|
- name: Comment PR with results
|
||||||
|
if: always()
|
||||||
|
uses: EnricoMi/publish-unit-test-result-action@v2
|
||||||
|
with:
|
||||||
|
files: ./TestResults/*.trx
|
||||||
|
comment_mode: failures
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ Read the Wiki for usage guides and configuration details.
|
|||||||
| 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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## GPG Verification / GPG 签名校验
|
## GPG Verification / GPG 签名校验
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"test": {
|
||||||
|
"runner": "Microsoft.Testing.Platform"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,8 +13,8 @@ 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_TFM="net10.0"
|
DOTNET_TFM="net10.0"
|
||||||
DOTNET_LOONGARCH_VERSION="10.0.109"
|
DOTNET_LOONGARCH_VERSION="10.0.111"
|
||||||
DOTNET_LOONGARCH_TAG="v10.0.109-loongarch64"
|
DOTNET_LOONGARCH_TAG="v10.0.111-loongarch64"
|
||||||
DOTNET_LOONGARCH_BASE="https://github.com/loongson/dotnet/releases/download"
|
DOTNET_LOONGARCH_BASE="https://github.com/loongson/dotnet/releases/download"
|
||||||
DOTNET_LOONGARCH_FILE="dotnet-sdk-${DOTNET_LOONGARCH_VERSION}-linux-loongarch64.tar.gz"
|
DOTNET_LOONGARCH_FILE="dotnet-sdk-${DOTNET_LOONGARCH_VERSION}-linux-loongarch64.tar.gz"
|
||||||
DOTNET_SDK_URL="${DOTNET_LOONGARCH_BASE}/${DOTNET_LOONGARCH_TAG}/${DOTNET_LOONGARCH_FILE}"
|
DOTNET_SDK_URL="${DOTNET_LOONGARCH_BASE}/${DOTNET_LOONGARCH_TAG}/${DOTNET_LOONGARCH_FILE}"
|
||||||
@@ -597,7 +597,7 @@ 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.2.0), coreutils (>= 9.5), bash (>= 5.2.26), 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"
|
||||||
cat > "$workdir/debian/control" <<EOF
|
cat > "$workdir/debian/control" <<EOF
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ MIN_KERNEL="5.10"
|
|||||||
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.109"
|
DOTNET_RISCV_VERSION="10.0.111"
|
||||||
DOTNET_RISCV_BASE="https://github.com/xujiegb/dotnet-riscv/releases/download"
|
DOTNET_RISCV_BASE="https://github.com/xujiegb/dotnet-riscv/releases/download"
|
||||||
DOTNET_RISCV_FILE="dotnet-sdk-${DOTNET_RISCV_VERSION}-linux-riscv64.tar.gz"
|
DOTNET_RISCV_FILE="dotnet-sdk-${DOTNET_RISCV_VERSION}-linux-riscv64.tar.gz"
|
||||||
DOTNET_SDK_URL="${DOTNET_RISCV_BASE}/${DOTNET_RISCV_VERSION}/${DOTNET_RISCV_FILE}"
|
DOTNET_SDK_URL="${DOTNET_RISCV_BASE}/${DOTNET_RISCV_VERSION}/${DOTNET_RISCV_FILE}"
|
||||||
@@ -595,7 +595,7 @@ 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.2.0), coreutils (>= 9.5), bash (>= 5.2.26), 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"
|
||||||
cat > "$workdir/debian/control" <<EOF
|
cat > "$workdir/debian/control" <<EOF
|
||||||
|
|||||||
+1
-1
@@ -609,7 +609,7 @@ 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.2.0), coreutils (>= 9.5), bash (>= 5.2.26), 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"
|
||||||
cat > "$workdir/debian/control" <<EOF
|
cat > "$workdir/debian/control" <<EOF
|
||||||
|
|||||||
+1
-1
@@ -54,7 +54,7 @@ cat >"$PackagePath/v2rayN.app/Contents/Info.plist" <<-EOF
|
|||||||
<key>NSHighResolutionCapable</key>
|
<key>NSHighResolutionCapable</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>LSMinimumSystemVersion</key>
|
<key>LSMinimumSystemVersion</key>
|
||||||
<string>13.7</string>
|
<string>13.6</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
EOF
|
EOF
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ 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_LOONGARCH_VERSION="10.0.109"
|
DOTNET_LOONGARCH_VERSION="10.0.111"
|
||||||
DOTNET_LOONGARCH_TAG="v10.0.109-loongarch64"
|
DOTNET_LOONGARCH_TAG="v10.0.111-loongarch64"
|
||||||
DOTNET_LOONGARCH_BASE="https://github.com/loongson/dotnet/releases/download"
|
DOTNET_LOONGARCH_BASE="https://github.com/loongson/dotnet/releases/download"
|
||||||
DOTNET_LOONGARCH_FILE="dotnet-sdk-${DOTNET_LOONGARCH_VERSION}-linux-loongarch64.tar.gz"
|
DOTNET_LOONGARCH_FILE="dotnet-sdk-${DOTNET_LOONGARCH_VERSION}-linux-loongarch64.tar.gz"
|
||||||
DOTNET_SDK_URL="${DOTNET_LOONGARCH_BASE}/${DOTNET_LOONGARCH_TAG}/${DOTNET_LOONGARCH_FILE}"
|
DOTNET_SDK_URL="${DOTNET_LOONGARCH_BASE}/${DOTNET_LOONGARCH_TAG}/${DOTNET_LOONGARCH_FILE}"
|
||||||
@@ -513,9 +513,9 @@ Requires: cairo, pango, openssl, mesa-libEGL, mesa-libGL
|
|||||||
Requires: glibc >= 2.39
|
Requires: glibc >= 2.39
|
||||||
Requires: fontconfig >= 2.15.0
|
Requires: fontconfig >= 2.15.0
|
||||||
Requires: desktop-file-utils >= 0.26
|
Requires: desktop-file-utils >= 0.26
|
||||||
Requires: xdg-utils >= 1.2.0
|
Requires: xdg-utils >= 1.1.3
|
||||||
Requires: coreutils >= 9.5
|
Requires: coreutils >= 9.4
|
||||||
Requires: bash >= 5.2.26
|
Requires: bash >= 5.2.21
|
||||||
Requires: freetype >= 2.13
|
Requires: freetype >= 2.13
|
||||||
|
|
||||||
%description
|
%description
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ MIN_KERNEL="5.10"
|
|||||||
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.109"
|
DOTNET_RISCV_VERSION="10.0.111"
|
||||||
DOTNET_RISCV_BASE="https://github.com/xujiegb/dotnet-riscv/releases/download"
|
DOTNET_RISCV_BASE="https://github.com/xujiegb/dotnet-riscv/releases/download"
|
||||||
DOTNET_RISCV_FILE="dotnet-sdk-${DOTNET_RISCV_VERSION}-linux-riscv64.tar.gz"
|
DOTNET_RISCV_FILE="dotnet-sdk-${DOTNET_RISCV_VERSION}-linux-riscv64.tar.gz"
|
||||||
DOTNET_SDK_URL="${DOTNET_RISCV_BASE}/${DOTNET_RISCV_VERSION}/${DOTNET_RISCV_FILE}"
|
DOTNET_SDK_URL="${DOTNET_RISCV_BASE}/${DOTNET_RISCV_VERSION}/${DOTNET_RISCV_FILE}"
|
||||||
@@ -512,9 +512,9 @@ Requires: cairo, pango, openssl, mesa-libEGL, mesa-libGL
|
|||||||
Requires: glibc >= 2.39
|
Requires: glibc >= 2.39
|
||||||
Requires: fontconfig >= 2.15.0
|
Requires: fontconfig >= 2.15.0
|
||||||
Requires: desktop-file-utils >= 0.26
|
Requires: desktop-file-utils >= 0.26
|
||||||
Requires: xdg-utils >= 1.2.0
|
Requires: xdg-utils >= 1.1.3
|
||||||
Requires: coreutils >= 9.5
|
Requires: coreutils >= 9.4
|
||||||
Requires: bash >= 5.2.26
|
Requires: bash >= 5.2.21
|
||||||
Requires: freetype >= 2.13
|
Requires: freetype >= 2.13
|
||||||
|
|
||||||
%description
|
%description
|
||||||
|
|||||||
+3
-3
@@ -501,9 +501,9 @@ Requires: cairo, pango, openssl, mesa-libEGL, mesa-libGL
|
|||||||
Requires: glibc >= 2.39
|
Requires: glibc >= 2.39
|
||||||
Requires: fontconfig >= 2.15.0
|
Requires: fontconfig >= 2.15.0
|
||||||
Requires: desktop-file-utils >= 0.26
|
Requires: desktop-file-utils >= 0.26
|
||||||
Requires: xdg-utils >= 1.2.0
|
Requires: xdg-utils >= 1.1.3
|
||||||
Requires: coreutils >= 9.5
|
Requires: coreutils >= 9.4
|
||||||
Requires: bash >= 5.2.26
|
Requires: bash >= 5.2.21
|
||||||
Requires: freetype >= 2.13
|
Requires: freetype >= 2.13
|
||||||
|
|
||||||
%description
|
%description
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<Project>
|
<Project>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Version>7.24.0</Version>
|
<Version>7.25.2</Version>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
|
|||||||
@@ -6,32 +6,30 @@
|
|||||||
</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.0" />
|
<PackageVersion Include="Avalonia.Controls.DataGrid" Version="12.1.2" />
|
||||||
<PackageVersion Include="Avalonia.Desktop" Version="12.1.0" />
|
<PackageVersion Include="Avalonia.Desktop" Version="12.1.2" />
|
||||||
<PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3" />
|
<PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3" />
|
||||||
<PackageVersion Include="AwesomeAssertions" Version="9.4.0" />
|
<PackageVersion Include="DialogHost.Avalonia" Version="0.12.3" />
|
||||||
<PackageVersion Include="DialogHost.Avalonia" Version="0.12.2" />
|
|
||||||
<PackageVersion Include="IPNetwork2" Version="4.3.0" />
|
<PackageVersion Include="IPNetwork2" Version="4.3.0" />
|
||||||
<PackageVersion Include="ReactiveUI.Avalonia" Version="12.0.3" />
|
<PackageVersion Include="ReactiveUI.Avalonia" Version="12.1.2" />
|
||||||
<PackageVersion Include="CliWrap" Version="3.10.2" />
|
<PackageVersion Include="CliWrap" Version="3.10.5" />
|
||||||
<PackageVersion Include="Downloader" Version="5.9.4" />
|
<PackageVersion Include="Downloader" Version="5.9.6" />
|
||||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.7.0" />
|
|
||||||
<PackageVersion Include="H.NotifyIcon.Wpf" Version="2.4.1" />
|
<PackageVersion Include="H.NotifyIcon.Wpf" Version="2.4.1" />
|
||||||
<PackageVersion Include="MaterialDesignThemes" Version="5.3.2" />
|
<PackageVersion Include="MaterialDesignThemes" Version="5.3.2" />
|
||||||
<PackageVersion Include="QRCoder" Version="1.8.0" />
|
<PackageVersion Include="QRCoder" Version="1.8.0" />
|
||||||
<PackageVersion Include="ReactiveUI" Version="23.2.28" />
|
<PackageVersion Include="ReactiveUI" Version="24.2.0" />
|
||||||
<PackageVersion Include="ReactiveUI.Fody" Version="19.5.41" />
|
<PackageVersion Include="ReactiveUI.SourceGenerators" Version="3.2.0" />
|
||||||
<PackageVersion Include="ReactiveUI.WPF" Version="23.2.28" />
|
<PackageVersion Include="ReactiveUI.WPF" Version="24.2.0" />
|
||||||
<PackageVersion Include="Semi.Avalonia" Version="12.1.0" />
|
<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" />
|
<PackageVersion Include="Semi.Avalonia.DataGrid" Version="12.1.0.1" />
|
||||||
<PackageVersion Include="NLog" Version="6.1.4" />
|
<PackageVersion Include="NLog" Version="6.2.1" />
|
||||||
<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.3" />
|
<PackageVersion Include="Repobot.SQLite.Unofficial" Version="3.53.4.1" />
|
||||||
<PackageVersion Include="TaskScheduler" Version="2.12.2" />
|
<PackageVersion Include="TaskScheduler" Version="2.12.2" />
|
||||||
|
<PackageVersion Include="TUnit" Version="1.68.17" />
|
||||||
|
<PackageVersion Include="TUnit.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="xunit.runner.visualstudio" Version="3.1.5" />
|
|
||||||
<PackageVersion Include="xunit.v3" Version="3.2.2" />
|
|
||||||
<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" />
|
||||||
|
|||||||
@@ -1,15 +1,8 @@
|
|||||||
using AwesomeAssertions;
|
|
||||||
using ServiceLib.Enums;
|
|
||||||
using ServiceLib.Handler.Builder;
|
|
||||||
using ServiceLib.Helper;
|
|
||||||
using ServiceLib.Models;
|
|
||||||
using Xunit;
|
|
||||||
|
|
||||||
namespace ServiceLib.Tests.CoreConfig.Context;
|
namespace ServiceLib.Tests.CoreConfig.Context;
|
||||||
|
|
||||||
public class CoreConfigContextBuilderTests
|
public class CoreConfigContextBuilderTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Test]
|
||||||
public async Task ResolveNodeAsync_DirectCycleDependency_ShouldFailWithCycleError()
|
public async Task ResolveNodeAsync_DirectCycleDependency_ShouldFailWithCycleError()
|
||||||
{
|
{
|
||||||
var config = CoreConfigTestFactory.CreateConfig();
|
var config = CoreConfigTestFactory.CreateConfig();
|
||||||
@@ -27,13 +20,13 @@ public class CoreConfigContextBuilderTests
|
|||||||
|
|
||||||
var (_, validatorResult) = await CoreConfigContextBuilder.ResolveNodeAsync(context, groupA, false);
|
var (_, validatorResult) = await CoreConfigContextBuilder.ResolveNodeAsync(context, groupA, false);
|
||||||
|
|
||||||
validatorResult.Success.Should().BeFalse();
|
await validatorResult.Success.Should().BeFalse();
|
||||||
validatorResult.Errors.Should().Contain(msg => ContainsCycleDependencyMessage(msg));
|
await validatorResult.Errors.Should().Contain(ContainsCycleDependencyMessage);
|
||||||
context.AllProxiesMap.Should().NotContainKey(groupA.IndexId);
|
await context.AllProxiesMap.Should().NotContainKey(groupA.IndexId);
|
||||||
context.AllProxiesMap.Should().NotContainKey(groupB.IndexId);
|
await context.AllProxiesMap.Should().NotContainKey(groupB.IndexId);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Test]
|
||||||
public async Task ResolveNodeAsync_IndirectCycleDependency_ShouldFailWithCycleError()
|
public async Task ResolveNodeAsync_IndirectCycleDependency_ShouldFailWithCycleError()
|
||||||
{
|
{
|
||||||
var config = CoreConfigTestFactory.CreateConfig();
|
var config = CoreConfigTestFactory.CreateConfig();
|
||||||
@@ -53,14 +46,14 @@ public class CoreConfigContextBuilderTests
|
|||||||
|
|
||||||
var (_, validatorResult) = await CoreConfigContextBuilder.ResolveNodeAsync(context, groupA, false);
|
var (_, validatorResult) = await CoreConfigContextBuilder.ResolveNodeAsync(context, groupA, false);
|
||||||
|
|
||||||
validatorResult.Success.Should().BeFalse();
|
await validatorResult.Success.Should().BeFalse();
|
||||||
validatorResult.Errors.Should().Contain(msg => ContainsCycleDependencyMessage(msg));
|
await validatorResult.Errors.Should().Contain(ContainsCycleDependencyMessage);
|
||||||
context.AllProxiesMap.Should().NotContainKey(groupA.IndexId);
|
await context.AllProxiesMap.Should().NotContainKey(groupA.IndexId);
|
||||||
context.AllProxiesMap.Should().NotContainKey(groupB.IndexId);
|
await context.AllProxiesMap.Should().NotContainKey(groupB.IndexId);
|
||||||
context.AllProxiesMap.Should().NotContainKey(groupC.IndexId);
|
await context.AllProxiesMap.Should().NotContainKey(groupC.IndexId);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Test]
|
||||||
public async Task ResolveNodeAsync_CycleWithValidBranch_ShouldSkipCycleAndKeepValidChild()
|
public async Task ResolveNodeAsync_CycleWithValidBranch_ShouldSkipCycleAndKeepValidChild()
|
||||||
{
|
{
|
||||||
var config = CoreConfigTestFactory.CreateConfig();
|
var config = CoreConfigTestFactory.CreateConfig();
|
||||||
@@ -80,14 +73,14 @@ public class CoreConfigContextBuilderTests
|
|||||||
|
|
||||||
var (_, validatorResult) = await CoreConfigContextBuilder.ResolveNodeAsync(context, groupA, false);
|
var (_, validatorResult) = await CoreConfigContextBuilder.ResolveNodeAsync(context, groupA, false);
|
||||||
|
|
||||||
validatorResult.Success.Should().BeTrue();
|
await validatorResult.Success.Should().BeTrue();
|
||||||
validatorResult.Errors.Should().BeEmpty();
|
await validatorResult.Errors.Should().BeEmpty();
|
||||||
validatorResult.Warnings.Should().Contain(msg => ContainsCycleDependencyMessage(msg));
|
await validatorResult.Warnings.Should().Contain(ContainsCycleDependencyMessage);
|
||||||
|
|
||||||
context.AllProxiesMap.Should().ContainKey(leaf.IndexId);
|
await context.AllProxiesMap.Should().ContainKey(leaf.IndexId);
|
||||||
context.AllProxiesMap.Should().ContainKey(groupA.IndexId);
|
await context.AllProxiesMap.Should().ContainKey(groupA.IndexId);
|
||||||
context.AllProxiesMap.Should().NotContainKey(groupB.IndexId);
|
await context.AllProxiesMap.Should().NotContainKey(groupB.IndexId);
|
||||||
groupA.GetProtocolExtra().ChildItems.Should().Be(leaf.IndexId);
|
await groupA.GetProtocolExtra().ChildItems.Should().BeEqualTo(leaf.IndexId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string NewId(string prefix)
|
private static string NewId(string prefix)
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ internal static class CoreConfigTestFactory
|
|||||||
},
|
},
|
||||||
WebDavItem = new WebDavItem(),
|
WebDavItem = new WebDavItem(),
|
||||||
CheckUpdateItem = new CheckUpdateItem(),
|
CheckUpdateItem = new CheckUpdateItem(),
|
||||||
Fragment4RayItem = new Fragment4RayItem { Packets = "tlshello", Length = "100-200", Interval = "10-20" },
|
Fragment4RayItem = new Fragment4RayItem { Packets = "tlshello", Lengths = ["100-200"], Delays = ["10-20"] },
|
||||||
Inbound =
|
Inbound =
|
||||||
[
|
[
|
||||||
new InItem
|
new InItem
|
||||||
@@ -84,6 +84,7 @@ internal static class CoreConfigTestFactory
|
|||||||
ParallelQuery = false,
|
ParallelQuery = false,
|
||||||
Strategy4Freedom = Global.AsIs,
|
Strategy4Freedom = Global.AsIs,
|
||||||
Strategy4Proxy = Global.AsIs,
|
Strategy4Proxy = Global.AsIs,
|
||||||
|
Strategy4ProxyDial = Global.AsIs,
|
||||||
},
|
},
|
||||||
IndexId = string.Empty,
|
IndexId = string.Empty,
|
||||||
SubIndexId = string.Empty,
|
SubIndexId = string.Empty,
|
||||||
@@ -149,6 +150,23 @@ internal static class CoreConfigTestFactory
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static ProfileItem CreateCustomOutboundNode(ECoreType coreType, string indexId = "node-custom-1",
|
||||||
|
string remarks = "demo-custom-outbound", string address = "custom_outbound.json")
|
||||||
|
{
|
||||||
|
return new ProfileItem
|
||||||
|
{
|
||||||
|
IndexId = indexId,
|
||||||
|
ConfigType = EConfigType.Outbound,
|
||||||
|
CoreType = coreType,
|
||||||
|
Remarks = remarks,
|
||||||
|
Address = address,
|
||||||
|
Port = 0,
|
||||||
|
Network = nameof(ETransport.raw),
|
||||||
|
StreamSecurity = string.Empty,
|
||||||
|
Subid = string.Empty,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
public static ProfileItem CreatePolicyGroupNode(ECoreType coreType, string indexId, string remarks,
|
public static ProfileItem CreatePolicyGroupNode(ECoreType coreType, string indexId, string remarks,
|
||||||
IEnumerable<string> childIndexIds)
|
IEnumerable<string> childIndexIds)
|
||||||
{
|
{
|
||||||
@@ -187,7 +205,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
|
||||||
{
|
{
|
||||||
@@ -208,6 +227,7 @@ internal static class CoreConfigTestFactory
|
|||||||
FullConfigTemplate = null,
|
FullConfigTemplate = null,
|
||||||
IsTunEnabled = config.TunModeItem.EnableTun,
|
IsTunEnabled = config.TunModeItem.EnableTun,
|
||||||
ProtectDomainList = [],
|
ProtectDomainList = [],
|
||||||
|
HasGlobalIPv6Address = hasGlobalIPv6Address,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,4 +253,12 @@ internal static class CoreConfigTestFactory
|
|||||||
config.TunModeItem.RouteExcludeAddress = ["10.0.0.1/32", "192.168.1.0/24", "fc00::/7"];
|
config.TunModeItem.RouteExcludeAddress = ["10.0.0.1/32", "192.168.1.0/24", "fc00::/7"];
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static Config CreateConfigWithTun(ECoreType coreType, bool enableIPv6Address)
|
||||||
|
{
|
||||||
|
var config = CreateConfig(coreType);
|
||||||
|
config.TunModeItem.EnableTun = true;
|
||||||
|
config.TunModeItem.EnableIPv6Address = enableIPv6Address;
|
||||||
|
return config;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,9 @@
|
|||||||
using AwesomeAssertions;
|
|
||||||
using ServiceLib.Common;
|
|
||||||
using ServiceLib.Enums;
|
|
||||||
using ServiceLib.Handler.Fmt;
|
|
||||||
using ServiceLib.Manager;
|
|
||||||
using ServiceLib.Models;
|
|
||||||
using ServiceLib.Models.Dto;
|
|
||||||
using ServiceLib.Services.CoreConfig;
|
|
||||||
using Xunit;
|
|
||||||
|
|
||||||
namespace ServiceLib.Tests.CoreConfig.Singbox;
|
namespace ServiceLib.Tests.CoreConfig.Singbox;
|
||||||
|
|
||||||
public class CoreConfigSingboxServiceTests
|
public class CoreConfigSingboxServiceTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Test]
|
||||||
public void GenerateClientConfigContent_ShouldGenerateBasicProxyConfig()
|
public async Task GenerateClientConfigContent_ShouldGenerateBasicProxyConfig()
|
||||||
{
|
{
|
||||||
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
|
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
|
||||||
CoreConfigTestFactory.BindAppManagerConfig(config);
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
@@ -22,17 +12,17 @@ public class CoreConfigSingboxServiceTests
|
|||||||
|
|
||||||
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
|
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
result.Success.Should().BeTrue($"ret msg: {result.Msg}");
|
await result.Success.Should().BeTrue().Because($"ret msg: {result.Msg}");
|
||||||
result.Data.Should().NotBeNull();
|
await result.Data.Should().NotBeNull();
|
||||||
|
|
||||||
var singboxConfig = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString());
|
var singboxConfig = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString());
|
||||||
singboxConfig.Should().NotBeNull();
|
await singboxConfig.Should().NotBeNull();
|
||||||
singboxConfig!.outbounds.Should().Contain(o => o.tag == Global.ProxyTag && o.type == "socks");
|
await singboxConfig!.outbounds.Should().Contain(o => o.tag == Global.ProxyTag && o.type == "socks");
|
||||||
singboxConfig.inbounds.Should().Contain(i => i.type == nameof(EInboundProtocol.mixed));
|
await singboxConfig.inbounds.Should().Contain(i => i.type == nameof(EInboundProtocol.mixed));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Test]
|
||||||
public void GenerateClientConfigContent_TunWithLoopbackPreSocks_ShouldKeepMixedInbound()
|
public async Task GenerateClientConfigContent_TunWithLoopbackPreSocks_ShouldKeepMixedInbound()
|
||||||
{
|
{
|
||||||
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
|
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
|
||||||
CoreConfigTestFactory.BindAppManagerConfig(config);
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
@@ -46,18 +36,100 @@ public class CoreConfigSingboxServiceTests
|
|||||||
|
|
||||||
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
|
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
result.Success.Should().BeTrue($"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())!;
|
||||||
|
|
||||||
cfg.inbounds.Should().Contain(i =>
|
await cfg.inbounds.Should().Contain(i =>
|
||||||
i.type == nameof(EInboundProtocol.mixed)
|
i.type == nameof(EInboundProtocol.mixed)
|
||||||
&& i.listen == Global.Loopback
|
&& i.listen == Global.Loopback
|
||||||
&& i.listen_port == AppManager.Instance.GetLocalPort(EInboundProtocol.socks));
|
&& i.listen_port == AppManager.Instance.GetLocalPort(EInboundProtocol.socks));
|
||||||
cfg.inbounds.Should().Contain(i => i.type == "tun");
|
await cfg.inbounds.Should().Contain(i => i.type == "tun");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Test]
|
||||||
public void GenerateClientConfigContent_BindInterface_ShouldUseDialBindInterface()
|
public async Task GenerateClientConfigContent_TunEnabled_ShouldKeepEmbeddedTunRules()
|
||||||
|
{
|
||||||
|
// The embedded tun rules reject local-network noise (NetBIOS/mDNS, multicast).
|
||||||
|
// They are deserialized into List<Rule4Sbox>, so a schema mismatch in the
|
||||||
|
// embedded template makes JsonUtils.Deserialize return null and silently
|
||||||
|
// drops every one of them.
|
||||||
|
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
|
||||||
|
config.TunModeItem.EnableTun = true;
|
||||||
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
|
|
||||||
|
var node = CoreConfigTestFactory.CreateVmessNode(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.Should().Contain(
|
||||||
|
r => r.action == "reject"
|
||||||
|
&& r.network != null && r.network.Contains("udp")
|
||||||
|
&& r.port != null && r.port.Contains(5353),
|
||||||
|
"the embedded tun rules must reject mDNS/NetBIOS noise");
|
||||||
|
await cfg.route.rules.Should().Contain(
|
||||||
|
r => r.action == "reject"
|
||||||
|
&& r.ip_cidr != null && r.ip_cidr.Contains("224.0.0.0/3"),
|
||||||
|
"the embedded tun rules must reject multicast traffic");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GenerateClientConfigContent_TunEnabled_ShouldRejectTrafficToTunOwnAddresses()
|
||||||
|
{
|
||||||
|
// Regression test: traffic addressed to the TUN interface's own addresses must
|
||||||
|
// never reach an outbound. auto_route hijacks the default route, so `direct`
|
||||||
|
// writes such a packet straight back into the TUN, which routes it to the
|
||||||
|
// outbound again - an infinite loop that pins a CPU core. Observed in the wild
|
||||||
|
// with WebRTC ICE connectivity checks against the TUN's own fc00::/7 ULA
|
||||||
|
// address, sustaining ~8k packets/s out of the TUN interface.
|
||||||
|
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
|
||||||
|
config.TunModeItem.EnableTun = true;
|
||||||
|
config.TunModeItem.EnableIPv6Address = true;
|
||||||
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
|
|
||||||
|
var node = CoreConfigTestFactory.CreateVmessNode(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");
|
||||||
|
//tun.address.Should().NotBeNullOrEmpty();
|
||||||
|
await tun.address.Should().NotBeNull();
|
||||||
|
await tun.address.Should().NotBeEmpty();
|
||||||
|
|
||||||
|
foreach (var address in tun.address!)
|
||||||
|
{
|
||||||
|
var self = IPAddress.Parse(address.Split('/').First());
|
||||||
|
var hostBits = self.AddressFamily == AddressFamily.InterNetworkV6 ? 128 : 32;
|
||||||
|
var expected = $"{self}/{hostBits}";
|
||||||
|
await cfg.route.rules.Should().Contain(
|
||||||
|
r => r.action == "reject" && r.ip_cidr != null && r.ip_cidr.Contains(expected),
|
||||||
|
$"traffic to the TUN's own address '{address}' must be rejected, not routed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The match has to stay on the addresses themselves. sing-tun derives the TUN's DNS
|
||||||
|
// entry from the address right after the interface's own, and every prefix offered
|
||||||
|
// here leaves room for it, so a prefix match would drop system name lookups too.
|
||||||
|
var dropRule = cfg.route.rules.First(r =>
|
||||||
|
r.action == "reject" && r.method == "drop" && r.ip_cidr?.Count > 0);
|
||||||
|
//dropRule.ip_cidr!.Should().OnlyContain(c =>
|
||||||
|
// c.EndsWith("/32", StringComparison.Ordinal) || c.EndsWith("/128", StringComparison.Ordinal));
|
||||||
|
await dropRule.ip_cidr.Should().All(c => c.EndsWith("/32", StringComparison.Ordinal) || c.EndsWith("/128", StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GenerateClientConfigContent_BindInterface_ShouldUseDialBindInterface()
|
||||||
{
|
{
|
||||||
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
|
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
|
||||||
config.CoreBasicItem.BindInterface = "eth0";
|
config.CoreBasicItem.BindInterface = "eth0";
|
||||||
@@ -71,16 +143,16 @@ public class CoreConfigSingboxServiceTests
|
|||||||
|
|
||||||
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
|
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
result.Success.Should().BeTrue($"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 proxy = cfg.outbounds.First(o => o.tag == Global.ProxyTag);
|
var proxy = cfg.outbounds.First(o => o.tag == Global.ProxyTag);
|
||||||
|
|
||||||
proxy.bind_interface.Should().Be("eth0");
|
await proxy.bind_interface.Should().BeEqualTo("eth0");
|
||||||
proxy.detour.Should().BeNullOrEmpty();
|
await proxy.detour.Should().BeNull().Or.BeEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Test]
|
||||||
public void GenerateClientConfigContent_PolicyGroup_ShouldExpandChildrenAndBuildSelector()
|
public async Task GenerateClientConfigContent_PolicyGroup_ShouldExpandChildrenAndBuildSelector()
|
||||||
{
|
{
|
||||||
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
|
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
|
||||||
CoreConfigTestFactory.BindAppManagerConfig(config);
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
@@ -97,17 +169,17 @@ public class CoreConfigSingboxServiceTests
|
|||||||
|
|
||||||
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
|
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
result.Success.Should().BeTrue($"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())!;
|
||||||
|
|
||||||
cfg.outbounds.Should().Contain(o => o.tag == Global.ProxyTag && o.type == "selector");
|
await cfg.outbounds.Should().Contain(o => o.tag == Global.ProxyTag && o.type == "selector");
|
||||||
cfg.outbounds.Should().Contain(o => o.tag == $"{Global.ProxyTag}-auto" && o.type == "urltest");
|
await cfg.outbounds.Should().Contain(o => o.tag == $"{Global.ProxyTag}-auto" && o.type == "urltest");
|
||||||
cfg.outbounds.Should().Contain(o => o.tag.StartsWith("proxy-1-", StringComparison.Ordinal));
|
await cfg.outbounds.Should().Contain(o => o.tag.StartsWith("proxy-1-", StringComparison.Ordinal));
|
||||||
cfg.outbounds.Should().Contain(o => o.tag.StartsWith("proxy-2-", StringComparison.Ordinal));
|
await cfg.outbounds.Should().Contain(o => o.tag.StartsWith("proxy-2-", StringComparison.Ordinal));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Test]
|
||||||
public void GenerateClientConfigContent_ProxyChain_ShouldBuildDetourChain()
|
public async Task GenerateClientConfigContent_ProxyChain_ShouldBuildDetourChain()
|
||||||
{
|
{
|
||||||
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
|
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
|
||||||
CoreConfigTestFactory.BindAppManagerConfig(config);
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
@@ -124,18 +196,18 @@ public class CoreConfigSingboxServiceTests
|
|||||||
|
|
||||||
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
|
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
result.Success.Should().BeTrue($"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())!;
|
||||||
|
|
||||||
cfg.outbounds.Should().Contain(o => o.tag == Global.ProxyTag && o.type == "socks");
|
await cfg.outbounds.Should().Contain(o => o.tag == Global.ProxyTag && o.type == "socks");
|
||||||
cfg.outbounds.Should().Contain(o => o.tag.StartsWith("chain-proxy-1-", StringComparison.Ordinal));
|
await cfg.outbounds.Should().Contain(o => o.tag.StartsWith("chain-proxy-1-", StringComparison.Ordinal));
|
||||||
cfg.outbounds.Should().Contain(o =>
|
await cfg.outbounds.Should().Contain(o =>
|
||||||
o.tag == Global.ProxyTag &&
|
o.tag == Global.ProxyTag &&
|
||||||
(o.detour ?? string.Empty).StartsWith("chain-proxy-1-", StringComparison.Ordinal));
|
(o.detour ?? string.Empty).StartsWith("chain-proxy-1-", StringComparison.Ordinal));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Test]
|
||||||
public void GenerateClientConfigContent_PolicyGroupWithProxyChain_ShouldBuildCombinedOutbounds()
|
public async Task GenerateClientConfigContent_PolicyGroupWithProxyChain_ShouldBuildCombinedOutbounds()
|
||||||
{
|
{
|
||||||
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
|
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
|
||||||
CoreConfigTestFactory.BindAppManagerConfig(config);
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
@@ -157,18 +229,18 @@ public class CoreConfigSingboxServiceTests
|
|||||||
|
|
||||||
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
|
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
result.Success.Should().BeTrue($"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())!;
|
||||||
|
|
||||||
cfg.outbounds.Should().Contain(o => o.tag == Global.ProxyTag && o.type == "selector");
|
await cfg.outbounds.Should().Contain(o => o.tag == Global.ProxyTag && o.type == "selector");
|
||||||
cfg.outbounds.Should().Contain(o => o.tag == $"{Global.ProxyTag}-auto" && o.type == "urltest");
|
await cfg.outbounds.Should().Contain(o => o.tag == $"{Global.ProxyTag}-auto" && o.type == "urltest");
|
||||||
cfg.outbounds.Should().Contain(o => o.tag.StartsWith("proxy-1-", StringComparison.Ordinal));
|
await cfg.outbounds.Should().Contain(o => o.tag.StartsWith("proxy-1-", StringComparison.Ordinal));
|
||||||
cfg.outbounds.Should().Contain(o => o.tag.StartsWith("chain-proxy-1-", StringComparison.Ordinal));
|
await cfg.outbounds.Should().Contain(o => o.tag.StartsWith("chain-proxy-1-", StringComparison.Ordinal));
|
||||||
cfg.outbounds.Should().Contain(o => o.tag.StartsWith("proxy-2-", StringComparison.Ordinal));
|
await cfg.outbounds.Should().Contain(o => o.tag.StartsWith("proxy-2-", StringComparison.Ordinal));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Test]
|
||||||
public void GenerateClientConfigContent_ProxyChainWithPolicyGroup_ShouldBuildClonedChainBranches()
|
public async Task GenerateClientConfigContent_ProxyChainWithPolicyGroup_ShouldBuildClonedChainBranches()
|
||||||
{
|
{
|
||||||
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
|
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
|
||||||
CoreConfigTestFactory.BindAppManagerConfig(config);
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
@@ -190,25 +262,25 @@ public class CoreConfigSingboxServiceTests
|
|||||||
|
|
||||||
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
|
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
result.Success.Should().BeTrue($"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())!;
|
||||||
|
|
||||||
cfg.outbounds.Should().Contain(o => o.tag == Global.ProxyTag && o.type == "selector");
|
await cfg.outbounds.Should().Contain(o => o.tag == Global.ProxyTag && o.type == "selector");
|
||||||
cfg.outbounds.Should().Contain(o => o.tag == $"{Global.ProxyTag}-auto" && o.type == "urltest");
|
await cfg.outbounds.Should().Contain(o => o.tag == $"{Global.ProxyTag}-auto" && o.type == "urltest");
|
||||||
cfg.outbounds.Should().Contain(o => o.tag.StartsWith("chain-proxy-1-group-1-", StringComparison.Ordinal));
|
await cfg.outbounds.Should().Contain(o => o.tag.StartsWith("chain-proxy-1-group-1-", StringComparison.Ordinal));
|
||||||
cfg.outbounds.Should().Contain(o => o.tag.StartsWith("chain-proxy-1-group-2-", StringComparison.Ordinal));
|
await cfg.outbounds.Should().Contain(o => o.tag.StartsWith("chain-proxy-1-group-2-", StringComparison.Ordinal));
|
||||||
|
|
||||||
var proxyCloneCount = cfg.outbounds.Count(o => o.tag.StartsWith("proxy-clone-", StringComparison.Ordinal));
|
var proxyCloneCount = cfg.outbounds.Count(o => o.tag.StartsWith("proxy-clone-", StringComparison.Ordinal));
|
||||||
proxyCloneCount.Should().Be(2);
|
await proxyCloneCount.Should().BeEqualTo(2);
|
||||||
|
|
||||||
var allCloneDetoursPointToGroupBranches = cfg.outbounds
|
var allCloneDetoursPointToGroupBranches = cfg.outbounds
|
||||||
.Where(o => o.tag.StartsWith("proxy-clone-", StringComparison.Ordinal))
|
.Where(o => o.tag.StartsWith("proxy-clone-", StringComparison.Ordinal))
|
||||||
.All(o => (o.detour ?? string.Empty).StartsWith("chain-proxy-1-group-", StringComparison.Ordinal));
|
.All(o => (o.detour ?? string.Empty).StartsWith("chain-proxy-1-group-", StringComparison.Ordinal));
|
||||||
allCloneDetoursPointToGroupBranches.Should().BeTrue();
|
await allCloneDetoursPointToGroupBranches.Should().BeTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Test]
|
||||||
public void GenerateClientConfigContent_RoutingSplit_DirectAndBlock_ShouldApplyRules()
|
public async Task GenerateClientConfigContent_RoutingSplit_DirectAndBlock_ShouldApplyRules()
|
||||||
{
|
{
|
||||||
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
|
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
|
||||||
CoreConfigTestFactory.BindAppManagerConfig(config);
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
@@ -244,24 +316,24 @@ public class CoreConfigSingboxServiceTests
|
|||||||
|
|
||||||
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
|
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
result.Success.Should().BeTrue($"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 hasDirectRule = cfg.route.rules.Any(r =>
|
var hasDirectRule = cfg.route.rules.Any(r =>
|
||||||
r.domain != null
|
r.domain != null
|
||||||
&& r.domain.Contains("direct.example.com")
|
&& r.domain.Contains("direct.example.com")
|
||||||
&& r.outbound == Global.DirectTag);
|
&& r.outbound == Global.DirectTag);
|
||||||
hasDirectRule.Should().BeTrue();
|
await hasDirectRule.Should().BeTrue();
|
||||||
|
|
||||||
var hasBlockRule = cfg.route.rules.Any(r =>
|
var hasBlockRule = cfg.route.rules.Any(r =>
|
||||||
r.domain != null
|
r.domain != null
|
||||||
&& r.domain.Contains("block.example.com")
|
&& r.domain.Contains("block.example.com")
|
||||||
&& r.action == "reject");
|
&& r.action == "reject");
|
||||||
hasBlockRule.Should().BeTrue();
|
await hasBlockRule.Should().BeTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Test]
|
||||||
public void GenerateClientConfigContent_RoutingSplit_ByRemark_ShouldGenerateTargetOutbound()
|
public async Task GenerateClientConfigContent_RoutingSplit_ByRemark_ShouldGenerateTargetOutbound()
|
||||||
{
|
{
|
||||||
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
|
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
|
||||||
CoreConfigTestFactory.BindAppManagerConfig(config);
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
@@ -293,65 +365,21 @@ public class CoreConfigSingboxServiceTests
|
|||||||
|
|
||||||
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
|
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
result.Success.Should().BeTrue($"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 expectedPrefix = $"{routeNode.IndexId}-{Global.ProxyTag}-{routeNode.Remarks}";
|
var expectedPrefix = $"{routeNode.IndexId}-{Global.ProxyTag}-{routeNode.Remarks}";
|
||||||
|
|
||||||
cfg.outbounds.Should().Contain(o => o.tag.StartsWith(expectedPrefix, StringComparison.Ordinal));
|
await cfg.outbounds.Should().Contain(o => o.tag.StartsWith(expectedPrefix, StringComparison.Ordinal));
|
||||||
|
|
||||||
var hasRouteRule = cfg.route.rules.Any(r =>
|
var hasRouteRule = cfg.route.rules.Any(r =>
|
||||||
r.domain != null
|
r.domain != null
|
||||||
&& r.domain.Contains("route.example.com")
|
&& r.domain.Contains("route.example.com")
|
||||||
&& (r.outbound ?? string.Empty).StartsWith(expectedPrefix, StringComparison.Ordinal));
|
&& (r.outbound ?? string.Empty).StartsWith(expectedPrefix, StringComparison.Ordinal));
|
||||||
hasRouteRule.Should().BeTrue();
|
await hasRouteRule.Should().BeTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Test]
|
||||||
public void GenerateClientConfigContent_DirectExpectedIPs_ShouldApplyGeoipAndCidrToDirectDnsRule()
|
public async Task GenerateClientConfigContent_BootstrapDNS_ShouldConfigurePureIPResolver()
|
||||||
{
|
|
||||||
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();
|
|
||||||
|
|
||||||
result.Success.Should().BeTrue($"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;
|
|
||||||
|
|
||||||
hasExpectedRule.Should().BeTrue();
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void GenerateClientConfigContent_BootstrapDNS_ShouldConfigurePureIPResolver()
|
|
||||||
{
|
{
|
||||||
var bootstrapDns = "8.8.8.8";
|
var bootstrapDns = "8.8.8.8";
|
||||||
var config = CoreConfigTestFactory.CreateConfigWithBootstrapDNS(ECoreType.sing_box, bootstrapDns);
|
var config = CoreConfigTestFactory.CreateConfigWithBootstrapDNS(ECoreType.sing_box, bootstrapDns);
|
||||||
@@ -362,17 +390,17 @@ public class CoreConfigSingboxServiceTests
|
|||||||
|
|
||||||
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
|
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
result.Success.Should().BeTrue($"ret msg: {result.Msg}");
|
await result.Success.Should().BeTrue().Because($"ret msg: {result.Msg}");
|
||||||
config.SimpleDNSItem.BootstrapDNS.Should().Be(bootstrapDns);
|
await config.SimpleDNSItem.BootstrapDNS.Should().BeEqualTo(bootstrapDns);
|
||||||
|
|
||||||
var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!;
|
var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!;
|
||||||
var bootstrapServer = cfg.dns.servers?.FirstOrDefault(s => s.tag == Global.SingboxLocalDNSTag);
|
var bootstrapServer = cfg.dns?.servers.FirstOrDefault(s => s.tag == Global.SingboxLocalDNSTag);
|
||||||
bootstrapServer.Should().NotBeNull();
|
await bootstrapServer.Should().NotBeNull();
|
||||||
(bootstrapServer?.server ?? string.Empty).Should().Contain(bootstrapDns);
|
await bootstrapServer!.server.Should().Contain(bootstrapDns);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Test]
|
||||||
public void GenerateClientConfigContent_DnsFallback_LastRuleDirect_ShouldUseDirectFinalDns()
|
public async Task GenerateClientConfigContent_DnsFallback_LastRuleDirect_ShouldUseDirectFinalDns()
|
||||||
{
|
{
|
||||||
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
|
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
|
||||||
config.SimpleDNSItem.DirectDNS = "1.1.1.1";
|
config.SimpleDNSItem.DirectDNS = "1.1.1.1";
|
||||||
@@ -405,14 +433,14 @@ public class CoreConfigSingboxServiceTests
|
|||||||
|
|
||||||
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
|
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
result.Success.Should().BeTrue($"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())!;
|
||||||
|
|
||||||
cfg.dns.final.Should().Be(Global.SingboxDirectDNSTag);
|
await cfg.dns.final.Should().BeEqualTo(Global.SingboxDirectDNSTag);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Test]
|
||||||
public void GenerateClientConfigContent_DirectExpectedIPs_NonMatchingRegion_ShouldNotApplyExpectedRule()
|
public async Task GenerateClientConfigContent_DirectExpectedIPs_NonMatchingRegion_ShouldNotApplyExpectedRule()
|
||||||
{
|
{
|
||||||
var config =
|
var config =
|
||||||
CoreConfigTestFactory.CreateConfigWithDirectExpectedIPs(ECoreType.sing_box, "192.168.0.0/16,geoip:cn");
|
CoreConfigTestFactory.CreateConfigWithDirectExpectedIPs(ECoreType.sing_box, "192.168.0.0/16,geoip:cn");
|
||||||
@@ -442,21 +470,21 @@ public class CoreConfigSingboxServiceTests
|
|||||||
|
|
||||||
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
|
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
result.Success.Should().BeTrue($"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.server == Global.SingboxDirectDNSTag
|
r.server == Global.SingboxDirectDNSTag
|
||||||
&& r.ip_cidr?.Contains("192.168.0.0/16") == true
|
&& r.ip_cidr?.Contains("192.168.0.0/16") == true
|
||||||
&& r.rule_set?.Contains("geoip-cn") == true) ?? false;
|
&& r.rule_set?.Contains("geoip-cn") == true) ?? false;
|
||||||
hasExpectedRule.Should().BeFalse();
|
await hasExpectedRule.Should().BeFalse();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
[Test]
|
||||||
[InlineData("geosite:cn", "geosite-cn")]
|
[Arguments("geosite:cn", "geosite-cn")]
|
||||||
[InlineData("geosite:geolocation-cn", "geosite-geolocation-cn")]
|
[Arguments("geosite:geolocation-cn", "geosite-geolocation-cn")]
|
||||||
[InlineData("geosite:tld-cn", "geosite-tld-cn")]
|
[Arguments("geosite:tld-cn", "geosite-tld-cn")]
|
||||||
public void GenerateClientConfigContent_DirectExpectedIPs_RegionVariant_ShouldApplyExpectedRule(string domainTag,
|
public async Task GenerateClientConfigContent_DirectExpectedIPs_RegionVariant_ShouldApplyExpectedRule(string domainTag,
|
||||||
string expectedRuleSetTag)
|
string expectedRuleSetTag)
|
||||||
{
|
{
|
||||||
var config =
|
var config =
|
||||||
@@ -472,31 +500,38 @@ 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();
|
||||||
|
|
||||||
result.Success.Should().BeTrue($"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.server == Global.SingboxDirectDNSTag
|
r.ip_cidr?.Contains("192.168.0.0/16") == true
|
||||||
&& r.ip_cidr?.Contains("192.168.0.0/16") == true
|
&& r.action == "respond"
|
||||||
&& r.rule_set?.Contains(expectedRuleSetTag) == true
|
&& r.rule_set?.Contains("geoip-cn") == true) ?? false)
|
||||||
&& r.rule_set?.Contains("geoip-cn") == true) ?? false;
|
&& (cfg.dns.rules?.Any(r =>
|
||||||
hasExpectedRule.Should().BeTrue();
|
r.server == Global.SingboxDirectDNSTag
|
||||||
|
&& r.rule_set?.Contains(expectedRuleSetTag) == true
|
||||||
|
&& r.action == "evaluate") ?? false);
|
||||||
|
await hasExpectedRule.Should().BeTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Test]
|
||||||
public void GenerateClientConfigContent_Hosts_ShouldPopulateHostsServerAndDomainResolver()
|
public async Task GenerateClientConfigContent_Hosts_ShouldPopulateHostsServerAndDomainResolver()
|
||||||
{
|
{
|
||||||
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
|
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
|
||||||
config.SimpleDNSItem.Hosts = "resolver.example 1.1.1.1";
|
config.SimpleDNSItem.Hosts = "resolver.example 1.1.1.1";
|
||||||
@@ -508,21 +543,21 @@ public class CoreConfigSingboxServiceTests
|
|||||||
|
|
||||||
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
|
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
result.Success.Should().BeTrue($"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 hostsServer = cfg.dns.servers.FirstOrDefault(s => s.tag == Global.SingboxHostsDNSTag);
|
var hostsServer = cfg.dns.servers.FirstOrDefault(s => s.tag == Global.SingboxHostsDNSTag);
|
||||||
hostsServer.Should().NotBeNull();
|
await hostsServer.Should().NotBeNull();
|
||||||
hostsServer!.predefined.Should().ContainKey("resolver.example");
|
await hostsServer!.predefined.Should().ContainKey("resolver.example");
|
||||||
hostsServer.predefined!["resolver.example"].Should().Contain("1.1.1.1");
|
await hostsServer.predefined!["resolver.example"].Should().Contain("1.1.1.1");
|
||||||
|
|
||||||
var directServer = cfg.dns.servers.FirstOrDefault(s => s.tag == Global.SingboxDirectDNSTag);
|
var directServer = cfg.dns.servers.FirstOrDefault(s => s.tag == Global.SingboxDirectDNSTag);
|
||||||
directServer.Should().NotBeNull();
|
await directServer.Should().NotBeNull();
|
||||||
directServer!.domain_resolver.Should().Be(Global.SingboxHostsDNSTag);
|
await directServer!.domain_resolver.Should().BeEqualTo(Global.SingboxHostsDNSTag);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Test]
|
||||||
public void GenerateClientConfigContent_RawDnsEnabled_ShouldUseCustomDnsAndInjectLocalResolver()
|
public async Task GenerateClientConfigContent_RawDnsEnabled_ShouldUseCustomDnsAndInjectLocalResolver()
|
||||||
{
|
{
|
||||||
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
|
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
|
||||||
CoreConfigTestFactory.BindAppManagerConfig(config);
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
@@ -551,22 +586,76 @@ public class CoreConfigSingboxServiceTests
|
|||||||
|
|
||||||
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
|
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
result.Success.Should().BeTrue($"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())!;
|
||||||
|
|
||||||
cfg.dns.servers.Should().Contain(s => s.tag == "remote" && s.type == "udp" && s.server == "8.8.8.8");
|
await cfg.dns.servers.Should().Contain(s => s.tag == "remote" && s.type == "udp" && s.server == "8.8.8.8");
|
||||||
cfg.dns.servers.Should().Contain(s => s.tag == Global.SingboxLocalDNSTag);
|
await cfg.dns.servers.Should().Contain(s => s.tag == Global.SingboxLocalDNSTag);
|
||||||
cfg.dns.rules.Should().Contain(r => r.clash_mode == nameof(ERuleMode.Global));
|
await cfg.dns.rules.Should().Contain(r => r.clash_mode == nameof(ERuleMode.Global));
|
||||||
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));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Test]
|
||||||
public void GenerateClientConfigContent_Hysteria2Realm_ShouldEmitHttpsServerUrl()
|
[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]
|
||||||
|
public async Task GenerateClientConfigContent_Hysteria2Realm_ShouldEmitHttpsServerUrl()
|
||||||
{
|
{
|
||||||
var shareLink =
|
var shareLink =
|
||||||
"hysteria2+realm://public@realm.hy2.io/my-realm-id?auth=uuid&stun=turn.cloudflare.com%3A3478&sni=cloudflare.com&pinSHA256=xxx#Realm-Test";
|
"hysteria2+realm://public@realm.hy2.io/my-realm-id?auth=uuid&stun=turn.cloudflare.com%3A3478&sni=cloudflare.com&pinSHA256=xxx#Realm-Test";
|
||||||
var node = Hysteria2Fmt.ResolveRealm(shareLink, out _);
|
var node = Hysteria2Fmt.ResolveRealm(shareLink, out _);
|
||||||
node.Should().NotBeNull();
|
await node.Should().NotBeNull();
|
||||||
node!.CoreType = ECoreType.sing_box;
|
node!.CoreType = ECoreType.sing_box;
|
||||||
|
|
||||||
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
|
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
|
||||||
@@ -579,17 +668,88 @@ public class CoreConfigSingboxServiceTests
|
|||||||
|
|
||||||
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
|
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
result.Success.Should().BeTrue($"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 proxy = cfg.outbounds.First(o => o.tag == Global.ProxyTag);
|
var proxy = cfg.outbounds.First(o => o.tag == Global.ProxyTag);
|
||||||
|
|
||||||
proxy.type.Should().Be("hysteria2");
|
await proxy.type.Should().BeEqualTo("hysteria2");
|
||||||
proxy.realm.Should().NotBeNull();
|
await proxy.realm.Should().NotBeNull();
|
||||||
proxy.realm!.server_url.Should().StartWith("https://");
|
await proxy.realm!.server_url.Should().StartWith("https://");
|
||||||
proxy.realm.server_url.Should().Contain("realm.hy2.io");
|
await proxy.realm.server_url.Should().Contain("realm.hy2.io");
|
||||||
proxy.realm.token.Should().Be("public");
|
await proxy.realm.token.Should().BeEqualTo("public");
|
||||||
proxy.realm.realm_id.Should().Be("my-realm-id");
|
await proxy.realm.realm_id.Should().BeEqualTo("my-realm-id");
|
||||||
proxy.realm.stun_servers.Should().Contain("turn.cloudflare.com:3478");
|
await proxy.realm.stun_servers.Should().Contain("turn.cloudflare.com:3478");
|
||||||
proxy.server.Should().BeNull();
|
await proxy.server.Should().BeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GenerateClientConfigContent_TunSystemStackWithIpv6_ShouldUsePrefixWithPeerAddress()
|
||||||
|
{
|
||||||
|
// Regression test for #9820: sing-box fails with "need one more IPv6 address in
|
||||||
|
// first prefix for system stack" when the TUN inbound uses a /128 IPv6 prefix.
|
||||||
|
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
|
||||||
|
config.TunModeItem.EnableTun = true;
|
||||||
|
config.TunModeItem.Stack = "system";
|
||||||
|
config.TunModeItem.EnableIPv6Address = true;
|
||||||
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
|
|
||||||
|
var node = CoreConfigTestFactory.CreateVmessNode(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.address.Should().NotBeNull();
|
||||||
|
await tun.address.Should().NotBeEmpty();
|
||||||
|
foreach (var address in tun.address!)
|
||||||
|
{
|
||||||
|
var prefixLength = int.Parse(address[(address.LastIndexOf('/') + 1)..]);
|
||||||
|
var isIpv6 = address.Contains(':');
|
||||||
|
await prefixLength.Should().BeLessThanOrEqualTo(isIpv6 ? 126 : 30,
|
||||||
|
$"'{address}' must leave room for the peer address the system stack derives");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GenerateClientConfigContent_CustomOutbound_ShouldReplaceWithUserCustomOutboundJson()
|
||||||
|
{
|
||||||
|
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
|
||||||
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
|
|
||||||
|
var customNode = CoreConfigTestFactory.CreateCustomOutboundNode(ECoreType.sing_box, "n-custom", "custom-singbox");
|
||||||
|
var customJsonContent = """
|
||||||
|
{
|
||||||
|
"type": "shadowsocks",
|
||||||
|
"server": "1.2.3.4",
|
||||||
|
"server_port": 8388,
|
||||||
|
"method": "aes-128-gcm",
|
||||||
|
"password": "custom_password"
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
var context = CoreConfigTestFactory.CreateContext(config, customNode, ECoreType.sing_box);
|
||||||
|
context.CustomOutboundContent[customNode.IndexId] = customJsonContent;
|
||||||
|
|
||||||
|
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
|
await result.Success.Should().BeTrue().Because($"ret msg: {result.Msg}");
|
||||||
|
await result.Data.Should().NotBeNull();
|
||||||
|
|
||||||
|
var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString());
|
||||||
|
await cfg.Should().NotBeNull();
|
||||||
|
var proxyOutbound = cfg!.outbounds.FirstOrDefault(o => o.tag == Global.ProxyTag);
|
||||||
|
await proxyOutbound.Should().NotBeNull();
|
||||||
|
await proxyOutbound!.type.Should().BeEqualTo("shadowsocks");
|
||||||
|
await proxyOutbound.server.Should().BeEqualTo("1.2.3.4");
|
||||||
|
await proxyOutbound.server_port.Should().BeEqualTo(8388);
|
||||||
|
await proxyOutbound.method.Should().BeEqualTo("aes-128-gcm");
|
||||||
|
await proxyOutbound.password.Should().BeEqualTo("custom_password");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,9 @@
|
|||||||
using AwesomeAssertions;
|
|
||||||
using ServiceLib.Common;
|
|
||||||
using ServiceLib.Enums;
|
|
||||||
using ServiceLib.Models;
|
|
||||||
using ServiceLib.Services.CoreConfig;
|
|
||||||
using Xunit;
|
|
||||||
|
|
||||||
namespace ServiceLib.Tests.CoreConfig.V2ray;
|
namespace ServiceLib.Tests.CoreConfig.V2ray;
|
||||||
|
|
||||||
public class CoreConfigV2rayServiceTests
|
public class CoreConfigV2rayServiceTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Test]
|
||||||
public void GenerateClientConfigContent_ShouldGenerateBasicProxyConfig()
|
public async Task GenerateClientConfigContent_ShouldGenerateBasicProxyConfig()
|
||||||
{
|
{
|
||||||
var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
|
var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
|
||||||
CoreConfigTestFactory.BindAppManagerConfig(config);
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
@@ -19,17 +12,17 @@ public class CoreConfigV2rayServiceTests
|
|||||||
|
|
||||||
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
|
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
result.Success.Should().BeTrue();
|
await result.Success.Should().BeTrue();
|
||||||
result.Data.Should().NotBeNull();
|
await result.Data.Should().NotBeNull();
|
||||||
|
|
||||||
var v2rayConfig = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString());
|
var v2rayConfig = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString());
|
||||||
v2rayConfig.Should().NotBeNull();
|
await v2rayConfig.Should().NotBeNull();
|
||||||
v2rayConfig!.outbounds.Should().Contain(o => o.tag == Global.ProxyTag && o.protocol == "vmess");
|
await v2rayConfig!.outbounds.Should().Contain(o => o.tag == Global.ProxyTag && o.protocol == "vmess");
|
||||||
v2rayConfig.inbounds.Should().Contain(i => i.protocol == nameof(EInboundProtocol.mixed));
|
await v2rayConfig.inbounds.Should().Contain(i => i.protocol == nameof(EInboundProtocol.mixed));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Test]
|
||||||
public void GenerateClientConfigContent_HttpOutbound_ShouldEmitHeadersInSettings()
|
public async Task GenerateClientConfigContent_HttpOutbound_ShouldEmitHeadersInSettings()
|
||||||
{
|
{
|
||||||
var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
|
var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
|
||||||
CoreConfigTestFactory.BindAppManagerConfig(config);
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
@@ -42,27 +35,25 @@ public class CoreConfigV2rayServiceTests
|
|||||||
|
|
||||||
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
|
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
result.Success.Should().BeTrue();
|
await result.Success.Should().BeTrue();
|
||||||
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
|
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
|
||||||
var outbound = cfg.outbounds.First(o => o.tag == Global.ProxyTag && o.protocol == "http");
|
var outbound = cfg.outbounds.First(o => o.tag == Global.ProxyTag && o.protocol == "http");
|
||||||
|
|
||||||
outbound.settings.address?.ToString().Should().Be("proxy.example.com");
|
await outbound.settings.address!.ToString().Should().BeEqualTo("proxy.example.com");
|
||||||
outbound.settings.port.Should().Be(8080);
|
await outbound.settings.port.Should().BeEqualTo(8080);
|
||||||
outbound.settings.user.Should().Be("user");
|
await outbound.settings.user.Should().BeEqualTo("user");
|
||||||
outbound.settings.pass.Should().Be("pass");
|
await outbound.settings.pass.Should().BeEqualTo("pass");
|
||||||
outbound.settings.level.Should().Be(1);
|
await outbound.settings.level.Should().BeEqualTo(1);
|
||||||
outbound.settings.headers.Should().NotBeNull();
|
await outbound.settings.headers.Should().NotBeNull();
|
||||||
var headers = JsonUtils.ParseJson(outbound.settings.headers.ToString());
|
var headers = JsonUtils.ParseJson(outbound.settings.headers!.ToString());
|
||||||
headers["User-Agent"]!.GetValue<string>().Should().Be("v2rayN");
|
await headers["User-Agent"]!.GetValue<string>().Should().BeEqualTo("v2rayN");
|
||||||
headers["Set-Cookie"]!.AsArray()
|
await headers["Set-Cookie"]!.AsArray()
|
||||||
.Select(item => item!.GetValue<string>())
|
.Select(item => item!.GetValue<string>())
|
||||||
.Should().Equal("a=1", "b=2");
|
.Should().BeEquivalentTo(["a=1", "b=2"]);
|
||||||
outbound.settings.servers.Should().BeNull();
|
|
||||||
outbound.settings.vnext.Should().BeNull();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Test]
|
||||||
public void GenerateClientConfigContent_PolicyGroup_ShouldExpandChildrenAndBuildBalancer()
|
public async Task GenerateClientConfigContent_PolicyGroup_ShouldExpandChildrenAndBuildBalancer()
|
||||||
{
|
{
|
||||||
var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
|
var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
|
||||||
CoreConfigTestFactory.BindAppManagerConfig(config);
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
@@ -79,17 +70,17 @@ public class CoreConfigV2rayServiceTests
|
|||||||
|
|
||||||
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
|
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
result.Success.Should().BeTrue();
|
await result.Success.Should().BeTrue();
|
||||||
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
|
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
|
||||||
|
|
||||||
cfg.outbounds.Should().Contain(o => o.tag.StartsWith("proxy-1-", StringComparison.Ordinal));
|
await cfg.outbounds.Should().Contain(o => o.tag.StartsWith("proxy-1-", StringComparison.Ordinal));
|
||||||
cfg.outbounds.Should().Contain(o => o.tag.StartsWith("proxy-2-", StringComparison.Ordinal));
|
await cfg.outbounds.Should().Contain(o => o.tag.StartsWith("proxy-2-", StringComparison.Ordinal));
|
||||||
cfg.routing.balancers.Should().NotBeNull();
|
await cfg.routing.balancers.Should().NotBeNull();
|
||||||
cfg.routing.balancers!.Should().Contain(b => b.tag == Global.ProxyTag + Global.BalancerTagSuffix);
|
await cfg.routing.balancers!.Should().Contain(b => b.tag == Global.ProxyTag + Global.BalancerTagSuffix);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Test]
|
||||||
public void GenerateClientConfigContent_ProxyChain_ShouldBuildDialerProxyChain()
|
public async Task GenerateClientConfigContent_ProxyChain_ShouldBuildDialerProxyChain()
|
||||||
{
|
{
|
||||||
var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
|
var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
|
||||||
CoreConfigTestFactory.BindAppManagerConfig(config);
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
@@ -105,21 +96,21 @@ public class CoreConfigV2rayServiceTests
|
|||||||
|
|
||||||
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
|
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
result.Success.Should().BeTrue();
|
await result.Success.Should().BeTrue();
|
||||||
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
|
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
|
||||||
|
|
||||||
cfg.outbounds.Should().Contain(o => o.tag.StartsWith("chain-proxy-1-", StringComparison.Ordinal));
|
await cfg.outbounds.Should().Contain(o => o.tag.StartsWith("chain-proxy-1-", StringComparison.Ordinal));
|
||||||
var hasDialerChain = cfg.outbounds.Any(o =>
|
var hasDialerChain = cfg.outbounds.Any(o =>
|
||||||
o.tag == Global.ProxyTag
|
o.tag == Global.ProxyTag
|
||||||
&& o.streamSettings is not null
|
&& o.streamSettings is not null
|
||||||
&& o.streamSettings.sockopt is not null
|
&& o.streamSettings.sockopt is not null
|
||||||
&& (o.streamSettings.sockopt.dialerProxy ?? string.Empty).StartsWith("chain-proxy-1-",
|
&& (o.streamSettings.sockopt.dialerProxy ?? string.Empty).StartsWith("chain-proxy-1-",
|
||||||
StringComparison.Ordinal));
|
StringComparison.Ordinal));
|
||||||
hasDialerChain.Should().BeTrue();
|
await hasDialerChain.Should().BeTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Test]
|
||||||
public void GenerateClientConfigContent_PolicyGroupWithProxyChain_ShouldBuildCombinedOutbounds()
|
public async Task GenerateClientConfigContent_PolicyGroupWithProxyChain_ShouldBuildCombinedOutbounds()
|
||||||
{
|
{
|
||||||
var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
|
var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
|
||||||
CoreConfigTestFactory.BindAppManagerConfig(config);
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
@@ -140,18 +131,18 @@ public class CoreConfigV2rayServiceTests
|
|||||||
|
|
||||||
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
|
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
result.Success.Should().BeTrue();
|
await result.Success.Should().BeTrue();
|
||||||
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
|
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
|
||||||
|
|
||||||
cfg.outbounds.Should().Contain(o => o.tag.StartsWith("proxy-1-", StringComparison.Ordinal));
|
await cfg.outbounds.Should().Contain(o => o.tag.StartsWith("proxy-1-", StringComparison.Ordinal));
|
||||||
cfg.outbounds.Should().Contain(o => o.tag.StartsWith("chain-proxy-1-", StringComparison.Ordinal));
|
await cfg.outbounds.Should().Contain(o => o.tag.StartsWith("chain-proxy-1-", StringComparison.Ordinal));
|
||||||
cfg.outbounds.Should().Contain(o => o.tag.StartsWith("proxy-2-", StringComparison.Ordinal));
|
await cfg.outbounds.Should().Contain(o => o.tag.StartsWith("proxy-2-", StringComparison.Ordinal));
|
||||||
cfg.routing.balancers.Should().NotBeNull();
|
await cfg.routing.balancers.Should().NotBeNull();
|
||||||
cfg.routing.balancers!.Should().Contain(b => b.tag == Global.ProxyTag + Global.BalancerTagSuffix);
|
await cfg.routing.balancers!.Should().Contain(b => b.tag == Global.ProxyTag + Global.BalancerTagSuffix);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Test]
|
||||||
public void GenerateClientConfigContent_ProxyChainWithPolicyGroup_ShouldBuildClonedChainBranches()
|
public async Task GenerateClientConfigContent_ProxyChainWithPolicyGroup_ShouldBuildClonedChainBranches()
|
||||||
{
|
{
|
||||||
var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
|
var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
|
||||||
CoreConfigTestFactory.BindAppManagerConfig(config);
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
@@ -173,27 +164,27 @@ public class CoreConfigV2rayServiceTests
|
|||||||
|
|
||||||
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
|
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
result.Success.Should().BeTrue();
|
await result.Success.Should().BeTrue();
|
||||||
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
|
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
|
||||||
|
|
||||||
cfg.outbounds.Should().Contain(o => o.tag.StartsWith("chain-proxy-1-group-1-", StringComparison.Ordinal));
|
await cfg.outbounds.Should().Contain(o => o.tag.StartsWith("chain-proxy-1-group-1-", StringComparison.Ordinal));
|
||||||
cfg.outbounds.Should().Contain(o => o.tag.StartsWith("chain-proxy-1-group-2-", StringComparison.Ordinal));
|
await cfg.outbounds.Should().Contain(o => o.tag.StartsWith("chain-proxy-1-group-2-", StringComparison.Ordinal));
|
||||||
|
|
||||||
var proxyCloneCount = cfg.outbounds.Count(o => o.tag.StartsWith("proxy-clone-", StringComparison.Ordinal));
|
var proxyCloneCount = cfg.outbounds.Count(o => o.tag.StartsWith("proxy-clone-", StringComparison.Ordinal));
|
||||||
proxyCloneCount.Should().Be(2);
|
await proxyCloneCount.Should().BeEqualTo(2);
|
||||||
|
|
||||||
var allCloneDialersPointToGroupBranches = cfg.outbounds
|
var allCloneDialersPointToGroupBranches = cfg.outbounds
|
||||||
.Where(o => o.tag.StartsWith("proxy-clone-", StringComparison.Ordinal))
|
.Where(o => o.tag.StartsWith("proxy-clone-", StringComparison.Ordinal))
|
||||||
.All(o => (o.streamSettings?.sockopt?.dialerProxy ?? string.Empty).StartsWith("chain-proxy-1-group-",
|
.All(o => (o.streamSettings?.sockopt?.dialerProxy ?? string.Empty).StartsWith("chain-proxy-1-group-",
|
||||||
StringComparison.Ordinal));
|
StringComparison.Ordinal));
|
||||||
allCloneDialersPointToGroupBranches.Should().BeTrue();
|
await allCloneDialersPointToGroupBranches.Should().BeTrue();
|
||||||
|
|
||||||
cfg.routing.balancers.Should().NotBeNull();
|
await cfg.routing.balancers.Should().NotBeNull();
|
||||||
cfg.routing.balancers!.Should().Contain(b => b.tag == Global.ProxyTag + Global.BalancerTagSuffix);
|
await cfg.routing.balancers!.Should().Contain(b => b.tag == Global.ProxyTag + Global.BalancerTagSuffix);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Test]
|
||||||
public void GenerateClientConfigContent_RoutingSplit_DirectAndBlock_ShouldApplyRules()
|
public async Task GenerateClientConfigContent_RoutingSplit_DirectAndBlock_ShouldApplyRules()
|
||||||
{
|
{
|
||||||
var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
|
var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
|
||||||
CoreConfigTestFactory.BindAppManagerConfig(config);
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
@@ -229,24 +220,24 @@ public class CoreConfigV2rayServiceTests
|
|||||||
|
|
||||||
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
|
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
result.Success.Should().BeTrue();
|
await result.Success.Should().BeTrue();
|
||||||
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
|
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
|
||||||
|
|
||||||
var hasDirectRule = cfg.routing.rules.Any(r =>
|
var hasDirectRule = cfg.routing.rules.Any(r =>
|
||||||
r.domain != null
|
r.domain != null
|
||||||
&& r.domain.Contains("full:direct.example.com")
|
&& r.domain.Contains("full:direct.example.com")
|
||||||
&& r.outboundTag == Global.DirectTag);
|
&& r.outboundTag == Global.DirectTag);
|
||||||
hasDirectRule.Should().BeTrue();
|
await hasDirectRule.Should().BeTrue();
|
||||||
|
|
||||||
var hasBlockRule = cfg.routing.rules.Any(r =>
|
var hasBlockRule = cfg.routing.rules.Any(r =>
|
||||||
r.domain != null
|
r.domain != null
|
||||||
&& r.domain.Contains("full:block.example.com")
|
&& r.domain.Contains("full:block.example.com")
|
||||||
&& r.outboundTag == Global.BlockTag);
|
&& r.outboundTag == Global.BlockTag);
|
||||||
hasBlockRule.Should().BeTrue();
|
await hasBlockRule.Should().BeTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Test]
|
||||||
public void GenerateClientConfigContent_RoutingSplit_ByRemark_ShouldGenerateTargetOutbound()
|
public async Task GenerateClientConfigContent_RoutingSplit_ByRemark_ShouldGenerateTargetOutbound()
|
||||||
{
|
{
|
||||||
var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
|
var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
|
||||||
CoreConfigTestFactory.BindAppManagerConfig(config);
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
@@ -278,20 +269,20 @@ public class CoreConfigV2rayServiceTests
|
|||||||
|
|
||||||
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
|
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
result.Success.Should().BeTrue();
|
await result.Success.Should().BeTrue();
|
||||||
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
|
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
|
||||||
var expectedPrefix = $"{routeNode.IndexId}-{Global.ProxyTag}-{routeNode.Remarks}";
|
var expectedPrefix = $"{routeNode.IndexId}-{Global.ProxyTag}-{routeNode.Remarks}";
|
||||||
|
|
||||||
cfg.outbounds.Should().Contain(o => o.tag.StartsWith(expectedPrefix, StringComparison.Ordinal));
|
await cfg.outbounds.Should().Contain(o => o.tag.StartsWith(expectedPrefix, StringComparison.Ordinal));
|
||||||
var hasRouteRule = cfg.routing.rules.Any(r =>
|
var hasRouteRule = cfg.routing.rules.Any(r =>
|
||||||
r.domain != null
|
r.domain != null
|
||||||
&& r.domain.Contains("full:route.example.com")
|
&& r.domain.Contains("full:route.example.com")
|
||||||
&& (r.outboundTag ?? string.Empty).StartsWith(expectedPrefix, StringComparison.Ordinal));
|
&& (r.outboundTag ?? string.Empty).StartsWith(expectedPrefix, StringComparison.Ordinal));
|
||||||
hasRouteRule.Should().BeTrue();
|
await hasRouteRule.Should().BeTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Test]
|
||||||
public void GenerateClientConfigContent_DirectExpectedIPs_ShouldApplyExpectedIPsToDirectDnsServer()
|
public async Task GenerateClientConfigContent_DirectExpectedIPs_ShouldApplyExpectedIPsToDirectDnsServer()
|
||||||
{
|
{
|
||||||
var config = CoreConfigTestFactory.CreateConfigWithDirectExpectedIPs(ECoreType.Xray, "192.168.0.0/16,geoip:cn");
|
var config = CoreConfigTestFactory.CreateConfigWithDirectExpectedIPs(ECoreType.Xray, "192.168.0.0/16,geoip:cn");
|
||||||
CoreConfigTestFactory.BindAppManagerConfig(config);
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
@@ -320,7 +311,7 @@ public class CoreConfigV2rayServiceTests
|
|||||||
|
|
||||||
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
|
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
result.Success.Should().BeTrue();
|
await result.Success.Should().BeTrue();
|
||||||
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
|
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
|
||||||
var dns = JsonUtils.Deserialize<Dns4Ray>(JsonUtils.Serialize(cfg.dns))!;
|
var dns = JsonUtils.Deserialize<Dns4Ray>(JsonUtils.Serialize(cfg.dns))!;
|
||||||
|
|
||||||
@@ -335,11 +326,11 @@ public class CoreConfigV2rayServiceTests
|
|||||||
&& s.domains?.Contains("geosite:cn") == true
|
&& s.domains?.Contains("geosite:cn") == true
|
||||||
&& s.expectedIPs?.Contains("192.168.0.0/16") == true
|
&& s.expectedIPs?.Contains("192.168.0.0/16") == true
|
||||||
&& s.expectedIPs?.Contains("geoip:cn") == true);
|
&& s.expectedIPs?.Contains("geoip:cn") == true);
|
||||||
hasExpectedServer.Should().BeTrue();
|
await hasExpectedServer.Should().BeTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Test]
|
||||||
public void GenerateClientConfigContent_BootstrapDNS_ShouldApplyToDnsServerDomains()
|
public async Task GenerateClientConfigContent_BootstrapDNS_ShouldApplyToDnsServerDomains()
|
||||||
{
|
{
|
||||||
var bootstrapDns = "8.8.8.8";
|
var bootstrapDns = "8.8.8.8";
|
||||||
var config = CoreConfigTestFactory.CreateConfigWithBootstrapDNS(ECoreType.Xray, bootstrapDns);
|
var config = CoreConfigTestFactory.CreateConfigWithBootstrapDNS(ECoreType.Xray, bootstrapDns);
|
||||||
@@ -352,7 +343,7 @@ public class CoreConfigV2rayServiceTests
|
|||||||
|
|
||||||
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
|
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
result.Success.Should().BeTrue();
|
await result.Success.Should().BeTrue();
|
||||||
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
|
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
|
||||||
var dns = JsonUtils.Deserialize<Dns4Ray>(JsonUtils.Serialize(cfg.dns))!;
|
var dns = JsonUtils.Deserialize<Dns4Ray>(JsonUtils.Serialize(cfg.dns))!;
|
||||||
|
|
||||||
@@ -366,11 +357,11 @@ public class CoreConfigV2rayServiceTests
|
|||||||
s.address == bootstrapDns
|
s.address == bootstrapDns
|
||||||
&& s.domains?.Contains("full:dns-direct.example") == true
|
&& s.domains?.Contains("full:dns-direct.example") == true
|
||||||
&& s.domains?.Contains("full:dns-remote.example") == true);
|
&& s.domains?.Contains("full:dns-remote.example") == true);
|
||||||
hasBootstrapServer.Should().BeTrue();
|
await hasBootstrapServer.Should().BeTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Test]
|
||||||
public void GenerateClientConfigContent_DnsFallback_LastRuleDirect_ShouldUseDirectDnsServers()
|
public async Task GenerateClientConfigContent_DnsFallback_LastRuleDirect_ShouldUseDirectDnsServers()
|
||||||
{
|
{
|
||||||
var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
|
var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
|
||||||
config.SimpleDNSItem.DirectDNS = "1.1.1.1";
|
config.SimpleDNSItem.DirectDNS = "1.1.1.1";
|
||||||
@@ -403,7 +394,7 @@ public class CoreConfigV2rayServiceTests
|
|||||||
|
|
||||||
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
|
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
result.Success.Should().BeTrue();
|
await result.Success.Should().BeTrue();
|
||||||
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
|
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
|
||||||
var dns = JsonUtils.Deserialize<Dns4Ray>(JsonUtils.Serialize(cfg.dns))!;
|
var dns = JsonUtils.Deserialize<Dns4Ray>(JsonUtils.Serialize(cfg.dns))!;
|
||||||
var dnsServers = dns.servers
|
var dnsServers = dns.servers
|
||||||
@@ -415,14 +406,14 @@ public class CoreConfigV2rayServiceTests
|
|||||||
var hasDirectFallback = dnsServers.Any(s =>
|
var hasDirectFallback = dnsServers.Any(s =>
|
||||||
(s.tag ?? string.Empty).StartsWith(Global.DirectDnsTag, StringComparison.Ordinal)
|
(s.tag ?? string.Empty).StartsWith(Global.DirectDnsTag, StringComparison.Ordinal)
|
||||||
&& s.address == "1.1.1.1");
|
&& s.address == "1.1.1.1");
|
||||||
hasDirectFallback.Should().BeTrue();
|
await hasDirectFallback.Should().BeTrue();
|
||||||
|
|
||||||
var hasRemoteFallback = dnsServers.Any(s => s.address == "9.9.9.9");
|
var hasRemoteFallback = dnsServers.Any(s => s.address == "9.9.9.9");
|
||||||
hasRemoteFallback.Should().BeFalse();
|
await hasRemoteFallback.Should().BeFalse();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Test]
|
||||||
public void GenerateClientConfigContent_DirectExpectedIPs_NonMatchingRegion_ShouldNotApplyExpectedIPs()
|
public async Task GenerateClientConfigContent_DirectExpectedIPs_NonMatchingRegion_ShouldNotApplyExpectedIPs()
|
||||||
{
|
{
|
||||||
var config = CoreConfigTestFactory.CreateConfigWithDirectExpectedIPs(ECoreType.Xray, "192.168.0.0/16,geoip:cn");
|
var config = CoreConfigTestFactory.CreateConfigWithDirectExpectedIPs(ECoreType.Xray, "192.168.0.0/16,geoip:cn");
|
||||||
CoreConfigTestFactory.BindAppManagerConfig(config);
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
@@ -451,7 +442,7 @@ public class CoreConfigV2rayServiceTests
|
|||||||
|
|
||||||
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
|
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
result.Success.Should().BeTrue();
|
await result.Success.Should().BeTrue();
|
||||||
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
|
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
|
||||||
var dns = JsonUtils.Deserialize<Dns4Ray>(JsonUtils.Serialize(cfg.dns))!;
|
var dns = JsonUtils.Deserialize<Dns4Ray>(JsonUtils.Serialize(cfg.dns))!;
|
||||||
var dnsServers = dns.servers
|
var dnsServers = dns.servers
|
||||||
@@ -463,14 +454,14 @@ public class CoreConfigV2rayServiceTests
|
|||||||
var hasExpectedIPs = dnsServers.Any(s =>
|
var hasExpectedIPs = dnsServers.Any(s =>
|
||||||
s.expectedIPs?.Contains("192.168.0.0/16") == true
|
s.expectedIPs?.Contains("192.168.0.0/16") == true
|
||||||
|| s.expectedIPs?.Contains("geoip:cn") == true);
|
|| s.expectedIPs?.Contains("geoip:cn") == true);
|
||||||
hasExpectedIPs.Should().BeFalse();
|
await hasExpectedIPs.Should().BeFalse();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
[Test]
|
||||||
[InlineData("geosite:cn")]
|
[Arguments("geosite:cn")]
|
||||||
[InlineData("geosite:geolocation-cn")]
|
[Arguments("geosite:geolocation-cn")]
|
||||||
[InlineData("geosite:tld-cn")]
|
[Arguments("geosite:tld-cn")]
|
||||||
public void GenerateClientConfigContent_DirectExpectedIPs_RegionVariant_ShouldApplyExpectedIPs(string domainTag)
|
public async Task GenerateClientConfigContent_DirectExpectedIPs_RegionVariant_ShouldApplyExpectedIPs(string domainTag)
|
||||||
{
|
{
|
||||||
var config = CoreConfigTestFactory.CreateConfigWithDirectExpectedIPs(ECoreType.Xray, "192.168.0.0/16,geoip:cn");
|
var config = CoreConfigTestFactory.CreateConfigWithDirectExpectedIPs(ECoreType.Xray, "192.168.0.0/16,geoip:cn");
|
||||||
CoreConfigTestFactory.BindAppManagerConfig(config);
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
@@ -496,7 +487,7 @@ public class CoreConfigV2rayServiceTests
|
|||||||
|
|
||||||
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
|
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
result.Success.Should().BeTrue();
|
await result.Success.Should().BeTrue();
|
||||||
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
|
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
|
||||||
var dns = JsonUtils.Deserialize<Dns4Ray>(JsonUtils.Serialize(cfg.dns))!;
|
var dns = JsonUtils.Deserialize<Dns4Ray>(JsonUtils.Serialize(cfg.dns))!;
|
||||||
var dnsServers = dns.servers
|
var dnsServers = dns.servers
|
||||||
@@ -510,11 +501,11 @@ public class CoreConfigV2rayServiceTests
|
|||||||
&& s.domains?.Contains(domainTag) == true
|
&& s.domains?.Contains(domainTag) == true
|
||||||
&& s.expectedIPs?.Contains("192.168.0.0/16") == true
|
&& s.expectedIPs?.Contains("192.168.0.0/16") == true
|
||||||
&& s.expectedIPs?.Contains("geoip:cn") == true);
|
&& s.expectedIPs?.Contains("geoip:cn") == true);
|
||||||
hasExpectedServer.Should().BeTrue();
|
await hasExpectedServer.Should().BeTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Test]
|
||||||
public void GenerateClientConfigContent_Hosts_ShouldPopulateDnsHosts()
|
public async Task GenerateClientConfigContent_Hosts_ShouldPopulateDnsHosts()
|
||||||
{
|
{
|
||||||
var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
|
var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
|
||||||
config.SimpleDNSItem.Hosts = "resolver.example 1.1.1.1";
|
config.SimpleDNSItem.Hosts = "resolver.example 1.1.1.1";
|
||||||
@@ -525,17 +516,17 @@ public class CoreConfigV2rayServiceTests
|
|||||||
|
|
||||||
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
|
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
result.Success.Should().BeTrue();
|
await result.Success.Should().BeTrue();
|
||||||
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
|
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
|
||||||
var dns = JsonUtils.Deserialize<Dns4Ray>(JsonUtils.Serialize(cfg.dns))!;
|
var dns = JsonUtils.Deserialize<Dns4Ray>(JsonUtils.Serialize(cfg.dns))!;
|
||||||
|
|
||||||
dns.hosts.Should().NotBeNull();
|
await dns.hosts.Should().NotBeNull();
|
||||||
dns.hosts!.Should().ContainKey("resolver.example");
|
await dns.hosts!.Should().ContainKey("resolver.example");
|
||||||
JsonUtils.Serialize(dns.hosts!["resolver.example"]).Should().Contain("1.1.1.1");
|
await JsonUtils.Serialize(dns.hosts!["resolver.example"]).Should().Contain("1.1.1.1");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Test]
|
||||||
public void GenerateClientConfigContent_RawDnsEnabled_ShouldUseCustomDnsConfig()
|
public async Task GenerateClientConfigContent_RawDnsEnabled_ShouldUseCustomDnsConfig()
|
||||||
{
|
{
|
||||||
var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
|
var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
|
||||||
CoreConfigTestFactory.BindAppManagerConfig(config);
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
@@ -556,22 +547,114 @@ public class CoreConfigV2rayServiceTests
|
|||||||
|
|
||||||
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
|
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
result.Success.Should().BeTrue();
|
await result.Success.Should().BeTrue();
|
||||||
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
|
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
|
||||||
var dns = JsonUtils.Deserialize<Dns4Ray>(JsonUtils.Serialize(cfg.dns))!;
|
var dns = JsonUtils.Deserialize<Dns4Ray>(JsonUtils.Serialize(cfg.dns))!;
|
||||||
|
|
||||||
JsonUtils.Serialize(dns.servers).Should().Contain("8.8.8.8");
|
await JsonUtils.Serialize(dns.servers).Should().Contain("8.8.8.8");
|
||||||
dns.hosts.Should().NotBeNull();
|
await dns.hosts.Should().NotBeNull();
|
||||||
dns.hosts!.Should().ContainKey("raw.example");
|
await dns.hosts!.Should().ContainKey("raw.example");
|
||||||
JsonUtils.Serialize(dns.hosts!["raw.example"]).Should().Contain("1.1.1.1");
|
await JsonUtils.Serialize(dns.hosts!["raw.example"]).Should().Contain("1.1.1.1");
|
||||||
|
|
||||||
var directOutbound = cfg.outbounds.FirstOrDefault(o => o.tag == Global.DirectTag && o.protocol == "freedom");
|
var directOutbound = cfg.outbounds.FirstOrDefault(o => o.tag == Global.DirectTag && o.protocol == "freedom");
|
||||||
directOutbound.Should().NotBeNull();
|
await directOutbound.Should().NotBeNull();
|
||||||
directOutbound!.settings.domainStrategy.Should().Be("UseIPv4");
|
await directOutbound!.streamSettings.sockopt!.domainStrategy.Should().BeEqualTo("UseIPv4");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Test]
|
||||||
public void GenerateClientConfigContent_TunRouteExcludeAddress()
|
[Arguments(false)]
|
||||||
|
[Arguments(true)]
|
||||||
|
public async Task GenerateClientConfigContent_Tun_ShouldRouteIPv6IntoTunnel(bool enableIPv6Address)
|
||||||
|
{
|
||||||
|
var config = CoreConfigTestFactory.CreateConfigWithTun(ECoreType.Xray, enableIPv6Address);
|
||||||
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
|
|
||||||
|
var node = CoreConfigTestFactory.CreateVmessNode(ECoreType.Xray, "n-main", "main");
|
||||||
|
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.Xray);
|
||||||
|
|
||||||
|
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");
|
||||||
|
await tunInbound.settings.autoSystemRoutingTable.Should().Contain("::/0");
|
||||||
|
|
||||||
|
// EnableIPv6Address governs the interface address only, never the routing table.
|
||||||
|
await tunInbound.settings.gateway.Should().HaveCount(enableIPv6Address ? 2 : 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
[Arguments(true)]
|
||||||
|
[Arguments(false)]
|
||||||
|
public async Task GenerateClientConfigContent_Tun_ShouldSkipIPv6RouteWithoutGlobalIPv6(bool enableIPv6Address)
|
||||||
|
{
|
||||||
|
// A host without a global IPv6 address has no IPv6 traffic that could bypass the tunnel,
|
||||||
|
// while ::/0 would pull IPv6 attempts into a tunnel they cannot leave.
|
||||||
|
var config = CoreConfigTestFactory.CreateConfigWithTun(ECoreType.Xray, enableIPv6Address);
|
||||||
|
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_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]
|
||||||
|
public async Task GenerateClientConfigContent_TunRouteExcludeAddress_ShouldIncludeIPv6Ranges()
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
|
||||||
|
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(x => x.Contains(':'));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GenerateClientConfigContent_TunRouteExcludeAddress()
|
||||||
{
|
{
|
||||||
var config = CoreConfigTestFactory.CreateConfigWithTunRouteExcludeAddress(ECoreType.Xray);
|
var config = CoreConfigTestFactory.CreateConfigWithTunRouteExcludeAddress(ECoreType.Xray);
|
||||||
CoreConfigTestFactory.BindAppManagerConfig(config);
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
@@ -581,15 +664,53 @@ public class CoreConfigV2rayServiceTests
|
|||||||
|
|
||||||
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
|
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
result.Success.Should().BeTrue();
|
await result.Success.Should().BeTrue();
|
||||||
|
|
||||||
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
|
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
|
||||||
var tunInbound = cfg.inbounds.FirstOrDefault(i => i.protocol == "tun");
|
var tunInbound = cfg.inbounds.FirstOrDefault(i => i.protocol == "tun");
|
||||||
|
|
||||||
tunInbound.Should().NotBeNull();
|
await tunInbound.Should().NotBeNull();
|
||||||
|
|
||||||
tunInbound!.settings.autoSystemRoutingTable.Should().NotContain("0.0.0.0/0");
|
await tunInbound!.settings.autoSystemRoutingTable.Should().NotContain("0.0.0.0/0");
|
||||||
tunInbound!.settings.autoSystemRoutingTable.Should().Contain("10.0.0.0/32");
|
await tunInbound!.settings.autoSystemRoutingTable.Should().Contain("10.0.0.0/32");
|
||||||
tunInbound!.settings.autoSystemRoutingTable.Should().Contain("10.0.0.2/31");
|
await tunInbound!.settings.autoSystemRoutingTable.Should().Contain("10.0.0.2/31");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GenerateClientConfigContent_CustomOutbound_ShouldReplaceWithUserCustomOutboundJson()
|
||||||
|
{
|
||||||
|
var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
|
||||||
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
|
|
||||||
|
var customNode = CoreConfigTestFactory.CreateCustomOutboundNode(ECoreType.Xray, "n-custom", "custom-xray");
|
||||||
|
var customJsonContent = """
|
||||||
|
{
|
||||||
|
"protocol": "shadowsocks",
|
||||||
|
"settings": {
|
||||||
|
"servers": [
|
||||||
|
{
|
||||||
|
"address": "1.2.3.4",
|
||||||
|
"port": 8388,
|
||||||
|
"method": "aes-128-gcm",
|
||||||
|
"password": "custom_password"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
var context = CoreConfigTestFactory.CreateContext(config, customNode, ECoreType.Xray);
|
||||||
|
context.CustomOutboundContent[customNode.IndexId] = customJsonContent;
|
||||||
|
|
||||||
|
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
|
await result.Success.Should().BeTrue().Because($"ret msg: {result.Msg}");
|
||||||
|
await result.Data.Should().NotBeNull();
|
||||||
|
|
||||||
|
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString());
|
||||||
|
await cfg.Should().NotBeNull();
|
||||||
|
var proxyOutbound = cfg!.outbounds.FirstOrDefault(o => o.tag == Global.ProxyTag);
|
||||||
|
await proxyOutbound.Should().NotBeNull();
|
||||||
|
await proxyOutbound!.protocol.Should().BeEqualTo("shadowsocks");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,103 +1,354 @@
|
|||||||
using AwesomeAssertions;
|
|
||||||
using ServiceLib.Enums;
|
|
||||||
using ServiceLib.Handler.Fmt;
|
|
||||||
using ServiceLib.Models;
|
|
||||||
using Xunit;
|
|
||||||
|
|
||||||
namespace ServiceLib.Tests.Fmt;
|
namespace ServiceLib.Tests.Fmt;
|
||||||
|
|
||||||
public class FmtHandlerTests
|
public class FmtHandlerTests
|
||||||
{
|
{
|
||||||
[Fact]
|
/// <summary>
|
||||||
public void GetShareUriAndResolveConfig_Vmess_ShouldRoundTripBasicFields()
|
/// One profile factory per protocol that <see cref="FmtHandler.GetShareUri" /> can export.
|
||||||
|
/// The suite below asserts that this map and <see cref="Global.ProtocolShares" /> agree, so a
|
||||||
|
/// newly exportable protocol cannot be added without a round-trip case.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly Dictionary<EConfigType, Func<ProfileItem>> ShareProfileFactories = new()
|
||||||
|
{
|
||||||
|
[EConfigType.VMess] = CreateVmessProfile,
|
||||||
|
[EConfigType.Shadowsocks] = CreateShadowsocksProfile,
|
||||||
|
[EConfigType.SOCKS] = CreateSocksProfile,
|
||||||
|
[EConfigType.VLESS] = CreateVlessProfile,
|
||||||
|
[EConfigType.Trojan] = CreateTrojanProfile,
|
||||||
|
[EConfigType.Hysteria2] = CreateHysteria2Profile,
|
||||||
|
[EConfigType.TUIC] = CreateTuicProfile,
|
||||||
|
[EConfigType.WireGuard] = CreateWireguardProfile,
|
||||||
|
[EConfigType.Anytls] = CreateAnytlsProfile,
|
||||||
|
[EConfigType.Naive] = () => CreateNaiveProfile(false),
|
||||||
|
};
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task ShareUriSuite_ShouldCoverAndRoundTripEveryExportableProtocol()
|
||||||
|
{
|
||||||
|
var uncovered = string.Join(", ", Global.ProtocolShares.Keys.Except(ShareProfileFactories.Keys));
|
||||||
|
var unexpected = string.Join(", ", ShareProfileFactories.Keys.Except(Global.ProtocolShares.Keys));
|
||||||
|
|
||||||
|
await uncovered.Should().BeEqualTo(string.Empty);
|
||||||
|
await unexpected.Should().BeEqualTo(string.Empty);
|
||||||
|
|
||||||
|
foreach (var (configType, factory) in ShareProfileFactories)
|
||||||
|
{
|
||||||
|
var source = factory();
|
||||||
|
|
||||||
|
var resolved = await ExportThenImport(source);
|
||||||
|
|
||||||
|
await resolved.ConfigType.Should().BeEqualTo(configType);
|
||||||
|
await resolved.Address.Should().BeEqualTo(source.Address);
|
||||||
|
await resolved.Port.Should().BeEqualTo(source.Port);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GetShareUriAndResolveConfig_Vmess_ShouldRoundTripBasicFields()
|
||||||
{
|
{
|
||||||
var source = CreateVmessProfile();
|
var source = CreateVmessProfile();
|
||||||
|
|
||||||
var resolved = ExportThenImport(source);
|
var resolved = await ExportThenImport(source);
|
||||||
|
|
||||||
resolved.ConfigType.Should().Be(EConfigType.VMess);
|
await resolved.ConfigType.Should().BeEqualTo(EConfigType.VMess);
|
||||||
resolved.Remarks.Should().Be(source.Remarks);
|
await resolved.Remarks.Should().BeEqualTo(source.Remarks);
|
||||||
resolved.Address.Should().Be(source.Address);
|
await resolved.Address.Should().BeEqualTo(source.Address);
|
||||||
resolved.Port.Should().Be(source.Port);
|
await resolved.Port.Should().BeEqualTo(source.Port);
|
||||||
resolved.Password.Should().Be(source.Password);
|
await resolved.Password.Should().BeEqualTo(source.Password);
|
||||||
resolved.GetProtocolExtra().AlterId.Should().Be(source.GetProtocolExtra().AlterId);
|
await resolved.GetProtocolExtra().AlterId.Should().BeEqualTo(source.GetProtocolExtra().AlterId);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Test]
|
||||||
public void GetShareUriAndResolveConfig_Vless_ShouldRoundTripBasicFields()
|
public async Task GetShareUriAndResolveConfig_Vless_ShouldRoundTripBasicFields()
|
||||||
{
|
{
|
||||||
var source = CreateVlessProfile();
|
var source = CreateVlessProfile();
|
||||||
|
|
||||||
var resolved = ExportThenImport(source);
|
var resolved = await ExportThenImport(source);
|
||||||
|
|
||||||
resolved.ConfigType.Should().Be(EConfigType.VLESS);
|
await resolved.ConfigType.Should().BeEqualTo(EConfigType.VLESS);
|
||||||
resolved.Remarks.Should().Be(source.Remarks);
|
await resolved.Remarks.Should().BeEqualTo(source.Remarks);
|
||||||
resolved.Address.Should().Be(source.Address);
|
await resolved.Address.Should().BeEqualTo(source.Address);
|
||||||
resolved.Port.Should().Be(source.Port);
|
await resolved.Port.Should().BeEqualTo(source.Port);
|
||||||
resolved.Password.Should().Be(source.Password);
|
await resolved.Password.Should().BeEqualTo(source.Password);
|
||||||
resolved.GetProtocolExtra().VlessEncryption.Should().Be(Global.None);
|
await resolved.GetProtocolExtra().VlessEncryption.Should().BeEqualTo(Global.None);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Test]
|
||||||
public void GetShareUriAndResolveConfig_Shadowsocks_ShouldRoundTripBasicFields()
|
public async Task GetShareUriAndResolveConfig_Shadowsocks_ShouldRoundTripBasicFields()
|
||||||
{
|
{
|
||||||
var source = CreateShadowsocksProfile();
|
var source = CreateShadowsocksProfile();
|
||||||
|
|
||||||
var resolved = ExportThenImport(source);
|
var resolved = await ExportThenImport(source);
|
||||||
|
|
||||||
resolved.ConfigType.Should().Be(EConfigType.Shadowsocks);
|
await resolved.ConfigType.Should().BeEqualTo(EConfigType.Shadowsocks);
|
||||||
resolved.Remarks.Should().Be(source.Remarks);
|
await resolved.Remarks.Should().BeEqualTo(source.Remarks);
|
||||||
resolved.Address.Should().Be(source.Address);
|
await resolved.Address.Should().BeEqualTo(source.Address);
|
||||||
resolved.Port.Should().Be(source.Port);
|
await resolved.Port.Should().BeEqualTo(source.Port);
|
||||||
resolved.Password.Should().Be(source.Password);
|
await resolved.Password.Should().BeEqualTo(source.Password);
|
||||||
resolved.GetProtocolExtra().SsMethod.Should().Be(source.GetProtocolExtra().SsMethod);
|
await resolved.GetProtocolExtra().SsMethod.Should().BeEqualTo(source.GetProtocolExtra().SsMethod);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Test]
|
||||||
public void GetShareUriAndResolveConfig_Socks_ShouldRoundTripBasicFields()
|
public async Task GetShareUriAndResolveConfig_Socks_ShouldRoundTripBasicFields()
|
||||||
{
|
{
|
||||||
var source = CreateSocksProfile();
|
var source = CreateSocksProfile();
|
||||||
|
|
||||||
var resolved = ExportThenImport(source);
|
var resolved = await ExportThenImport(source);
|
||||||
|
|
||||||
resolved.ConfigType.Should().Be(EConfigType.SOCKS);
|
await resolved.ConfigType.Should().BeEqualTo(EConfigType.SOCKS);
|
||||||
resolved.Remarks.Should().Be(source.Remarks);
|
await resolved.Remarks.Should().BeEqualTo(source.Remarks);
|
||||||
resolved.Address.Should().Be(source.Address);
|
await resolved.Address.Should().BeEqualTo(source.Address);
|
||||||
resolved.Port.Should().Be(source.Port);
|
await resolved.Port.Should().BeEqualTo(source.Port);
|
||||||
resolved.Username.Should().Be(source.Username);
|
await resolved.Username.Should().BeEqualTo(source.Username);
|
||||||
resolved.Password.Should().Be(source.Password);
|
await resolved.Password.Should().BeEqualTo(source.Password);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Test]
|
||||||
public void ResolveConfig_UnsupportedProtocol_ShouldReturnNull()
|
public async Task GetShareUriAndResolveConfig_Trojan_ShouldRoundTripBasicFields()
|
||||||
|
{
|
||||||
|
var source = CreateTrojanProfile();
|
||||||
|
|
||||||
|
var resolved = await ExportThenImport(source);
|
||||||
|
|
||||||
|
await AssertCommonShareFields(source, resolved);
|
||||||
|
await AssertRawTransportFields(source, resolved);
|
||||||
|
await resolved.Password.Should().BeEqualTo(source.Password);
|
||||||
|
await resolved.Sni.Should().BeEqualTo(source.Sni);
|
||||||
|
await resolved.GetProtocolExtra().Flow.Should().BeEqualTo(source.GetProtocolExtra().Flow);
|
||||||
|
await resolved.GetAllowInsecure().Should().BeTrue();
|
||||||
|
|
||||||
|
// Trojan is the one exporter that writes both spellings of the flag.
|
||||||
|
await AssertExportContains(source, "allowInsecure=1", "insecure=1");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GetShareUriAndResolveConfig_Tuic_ShouldRoundTripUserInfoAndCongestionControl()
|
||||||
|
{
|
||||||
|
var source = CreateTuicProfile();
|
||||||
|
|
||||||
|
var resolved = await ExportThenImport(source);
|
||||||
|
|
||||||
|
await AssertCommonShareFields(source, resolved);
|
||||||
|
await resolved.Username.Should().BeEqualTo(source.Username);
|
||||||
|
await resolved.Password.Should().BeEqualTo(source.Password);
|
||||||
|
await resolved.Sni.Should().BeEqualTo(source.Sni);
|
||||||
|
await resolved.Alpn.Should().BeEqualTo(source.Alpn);
|
||||||
|
await resolved.GetProtocolExtra().CongestionControl.Should()
|
||||||
|
.BeEqualTo(source.GetProtocolExtra().CongestionControl);
|
||||||
|
await resolved.GetAllowInsecure().Should().BeTrue();
|
||||||
|
|
||||||
|
await AssertExportContains(source, "allow_insecure=1");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GetShareUriAndResolveConfig_Anytls_ShouldRoundTripBasicFields()
|
||||||
|
{
|
||||||
|
var source = CreateAnytlsProfile();
|
||||||
|
|
||||||
|
var resolved = await ExportThenImport(source);
|
||||||
|
|
||||||
|
await AssertCommonShareFields(source, resolved);
|
||||||
|
await AssertRawTransportFields(source, resolved);
|
||||||
|
await resolved.Password.Should().BeEqualTo(source.Password);
|
||||||
|
await resolved.Sni.Should().BeEqualTo(source.Sni);
|
||||||
|
await resolved.Alpn.Should().BeEqualTo(source.Alpn);
|
||||||
|
await resolved.GetAllowInsecure().Should().BeTrue();
|
||||||
|
|
||||||
|
await AssertExportContains(source, "insecure=1");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GetShareUriAndResolveConfig_Hysteria2_ShouldRoundTripObfsAndNormalizePortRange()
|
||||||
|
{
|
||||||
|
var source = CreateHysteria2Profile();
|
||||||
|
|
||||||
|
var resolved = await ExportThenImport(source);
|
||||||
|
var sourceExtra = source.GetProtocolExtra();
|
||||||
|
var resolvedExtra = resolved.GetProtocolExtra();
|
||||||
|
|
||||||
|
await AssertCommonShareFields(source, resolved);
|
||||||
|
await resolved.Password.Should().BeEqualTo(source.Password);
|
||||||
|
await resolved.Sni.Should().BeEqualTo(source.Sni);
|
||||||
|
await resolved.Alpn.Should().BeEqualTo(source.Alpn);
|
||||||
|
await resolved.EchConfigList.Should().BeEqualTo(source.EchConfigList);
|
||||||
|
await resolved.GetAllowInsecure().Should().BeTrue();
|
||||||
|
await resolvedExtra.SalamanderPass.Should().BeEqualTo(sourceExtra.SalamanderPass);
|
||||||
|
|
||||||
|
// Hysteria2Fmt stores a port range internally as "5000:6000" and emits the URI form.
|
||||||
|
await resolvedExtra.Ports.Should().BeEqualTo("5000-6000");
|
||||||
|
|
||||||
|
await AssertExportContains(source, "insecure=1", "obfs=salamander", "mport=5000-6000");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GetShareUriAndResolveConfig_Wireguard_ShouldRoundTripKeysAndInterface()
|
||||||
|
{
|
||||||
|
var source = CreateWireguardProfile();
|
||||||
|
|
||||||
|
var resolved = await ExportThenImport(source);
|
||||||
|
var extra = resolved.GetProtocolExtra();
|
||||||
|
var sourceExtra = source.GetProtocolExtra();
|
||||||
|
|
||||||
|
await AssertCommonShareFields(source, resolved);
|
||||||
|
await resolved.Password.Should().BeEqualTo(source.Password);
|
||||||
|
await extra.WgPublicKey.Should().BeEqualTo(sourceExtra.WgPublicKey);
|
||||||
|
await extra.WgPresharedKey.Should().BeEqualTo(sourceExtra.WgPresharedKey);
|
||||||
|
await extra.WgReserved.Should().BeEqualTo(sourceExtra.WgReserved);
|
||||||
|
await extra.WgInterfaceAddress.Should().BeEqualTo(sourceExtra.WgInterfaceAddress);
|
||||||
|
await extra.WgMtu.Should().BeEqualTo(sourceExtra.WgMtu);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GetShareUri_Wireguard_ShouldEncodeKeysAndBracketIpv6()
|
||||||
|
{
|
||||||
|
var source = CreateWireguardProfile();
|
||||||
|
|
||||||
|
// Base64 keys carry '/', '+' and '=', and the address is an IPv6 literal: both have to
|
||||||
|
// survive the wire form, which a round trip through the same encoder would not prove.
|
||||||
|
await AssertExportContains(
|
||||||
|
source,
|
||||||
|
Uri.EscapeDataString(source.Password),
|
||||||
|
Uri.EscapeDataString(source.GetProtocolExtra().WgPublicKey ?? string.Empty),
|
||||||
|
"@[2001:db8::40]:51820");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GetShareUriAndResolveConfig_Naive_ShouldRoundTripCredentialsOverHttps()
|
||||||
|
{
|
||||||
|
var source = CreateNaiveProfile(false);
|
||||||
|
|
||||||
|
var resolved = await ExportThenImport(source, Global.NaiveHttpsProtocolShare);
|
||||||
|
|
||||||
|
await AssertCommonShareFields(source, resolved);
|
||||||
|
await AssertRawTransportFields(source, resolved);
|
||||||
|
await resolved.Username.Should().BeEqualTo(source.Username);
|
||||||
|
await resolved.Password.Should().BeEqualTo(source.Password);
|
||||||
|
await resolved.GetProtocolExtra().InsecureConcurrency.Should()
|
||||||
|
.BeEqualTo(source.GetProtocolExtra().InsecureConcurrency);
|
||||||
|
|
||||||
|
// NaiveFmt only ever sets this flag on the quic branch, so the https branch leaves it
|
||||||
|
// unset rather than false - assert "is not quic" instead of an explicit false.
|
||||||
|
await (resolved.GetProtocolExtra().NaiveQuic == true).Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GetShareUriAndResolveConfig_NaiveQuic_ShouldRoundTripQuicScheme()
|
||||||
|
{
|
||||||
|
var source = CreateNaiveProfile(true);
|
||||||
|
|
||||||
|
var resolved = await ExportThenImport(source, Global.NaiveQuicProtocolShare);
|
||||||
|
|
||||||
|
await AssertCommonShareFields(source, resolved);
|
||||||
|
await AssertRawTransportFields(source, resolved);
|
||||||
|
await resolved.Username.Should().BeEqualTo(source.Username);
|
||||||
|
await resolved.Password.Should().BeEqualTo(source.Password);
|
||||||
|
await resolved.GetProtocolExtra().InsecureConcurrency.Should()
|
||||||
|
.BeEqualTo(source.GetProtocolExtra().InsecureConcurrency);
|
||||||
|
await resolved.GetProtocolExtra().NaiveQuic.Should().BeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
[Arguments("p:a@ss#% +/=")]
|
||||||
|
[Arguments("пароль 東京")]
|
||||||
|
public async Task GetShareUriAndResolveConfig_Trojan_ShouldRoundTripEncodedCredentials(string password)
|
||||||
|
{
|
||||||
|
var source = CreateTrojanProfile();
|
||||||
|
source.Password = password;
|
||||||
|
source.Remarks = "Trojan — тест 東京 #1";
|
||||||
|
|
||||||
|
var resolved = await ExportThenImport(source);
|
||||||
|
|
||||||
|
await resolved.Password.Should().BeEqualTo(password);
|
||||||
|
await resolved.Remarks.Should().BeEqualTo(source.Remarks);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task ResolveConfig_UnsupportedProtocol_ShouldReturnNull()
|
||||||
{
|
{
|
||||||
var resolved = FmtHandler.ResolveConfig("not-a-share-uri", out var msg);
|
var resolved = FmtHandler.ResolveConfig("not-a-share-uri", out var msg);
|
||||||
|
|
||||||
resolved.Should().BeNull();
|
await resolved.Should().BeNull();
|
||||||
msg.Should().NotBeNullOrWhiteSpace();
|
await msg.Should().NotBeNull();
|
||||||
|
await msg.Should().NotBeEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Test]
|
||||||
public void GetShareUri_UnsupportedConfigType_ShouldReturnNull()
|
public async Task GetShareUri_UnsupportedConfigType_ShouldReturnNull()
|
||||||
{
|
{
|
||||||
var item = new ProfileItem { ConfigType = EConfigType.PolicyGroup, Remarks = "group", };
|
var item = new ProfileItem { ConfigType = EConfigType.PolicyGroup, Remarks = "group", };
|
||||||
|
|
||||||
var uri = FmtHandler.GetShareUri(item);
|
var uri = FmtHandler.GetShareUri(item);
|
||||||
|
|
||||||
uri.Should().BeNull();
|
await uri.Should().BeNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ProfileItem ExportThenImport(ProfileItem source)
|
private static async Task AssertCommonShareFields(ProfileItem source, ProfileItem resolved)
|
||||||
|
{
|
||||||
|
await resolved.ConfigType.Should().BeEqualTo(source.ConfigType);
|
||||||
|
await resolved.Remarks.Should().BeEqualTo(source.Remarks);
|
||||||
|
await resolved.Address.Should().BeEqualTo(source.Address);
|
||||||
|
await resolved.Port.Should().BeEqualTo(source.Port);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Only for protocols whose exporter goes through the shared transport query
|
||||||
|
/// (<c>security</c>, <c>type</c>, <c>headerType</c>). TUIC, Hysteria2 and WireGuard do not.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task AssertRawTransportFields(ProfileItem source, ProfileItem resolved)
|
||||||
|
{
|
||||||
|
await resolved.Network.Should().BeEqualTo(source.Network);
|
||||||
|
await resolved.StreamSecurity.Should().BeEqualTo(source.StreamSecurity);
|
||||||
|
await resolved.GetTransportExtra().RawHeaderType.Should()
|
||||||
|
.BeEqualTo(source.GetTransportExtra().RawHeaderType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Asserts on the wire form itself. A round trip cannot catch an exporter and an importer that
|
||||||
|
/// agree on the wrong spelling of a parameter, and the insecure flag is spelled differently by
|
||||||
|
/// every protocol.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task AssertExportContains(ProfileItem source, params string[] expectedFragments)
|
||||||
{
|
{
|
||||||
var uri = FmtHandler.GetShareUri(source);
|
var uri = FmtHandler.GetShareUri(source);
|
||||||
|
|
||||||
uri.Should().NotBeNullOrWhiteSpace();
|
await uri.Should().NotBeNull();
|
||||||
uri!.StartsWith(Global.ProtocolShares[source.ConfigType], StringComparison.OrdinalIgnoreCase).Should()
|
|
||||||
.BeTrue();
|
foreach (var fragment in expectedFragments)
|
||||||
|
{
|
||||||
|
await uri!.Contains(fragment, StringComparison.Ordinal).Should()
|
||||||
|
.BeTrue().Because($"uri: {uri}, expected fragment: {fragment}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ExpectedShareScheme(ProfileItem item)
|
||||||
|
{
|
||||||
|
if (item.ConfigType != EConfigType.Naive)
|
||||||
|
{
|
||||||
|
return Global.ProtocolShares[item.ConfigType];
|
||||||
|
}
|
||||||
|
|
||||||
|
// NaiveFmt never emits the "naive://" prefix that Global.ProtocolShares records for the
|
||||||
|
// type; that entry is only read when importing.
|
||||||
|
return item.GetProtocolExtra().NaiveQuic == true
|
||||||
|
? Global.NaiveQuicProtocolShare
|
||||||
|
: Global.NaiveHttpsProtocolShare;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<ProfileItem> ExportThenImport(ProfileItem source)
|
||||||
|
{
|
||||||
|
return await ExportThenImport(source, ExpectedShareScheme(source));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<ProfileItem> ExportThenImport(ProfileItem source, string expectedPrefix)
|
||||||
|
{
|
||||||
|
var uri = FmtHandler.GetShareUri(source);
|
||||||
|
|
||||||
|
await uri.Should().NotBeNull();
|
||||||
|
await uri.Should().NotBeEmpty();
|
||||||
|
await uri!.StartsWith(expectedPrefix, StringComparison.OrdinalIgnoreCase).Should().BeTrue();
|
||||||
|
|
||||||
var resolved = FmtHandler.ResolveConfig(uri, out var msg);
|
var resolved = FmtHandler.ResolveConfig(uri, out var msg);
|
||||||
|
|
||||||
resolved.Should().NotBeNull($"uri: {uri}, msg: {msg}");
|
await resolved.Should().NotBeNull().Because($"uri: {uri}, msg: {msg}");
|
||||||
return resolved!;
|
return resolved!;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,4 +421,142 @@ public class FmtHandlerTests
|
|||||||
Password = "pass",
|
Password = "pass",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static ProfileItem CreateTrojanProfile()
|
||||||
|
{
|
||||||
|
var item = new ProfileItem
|
||||||
|
{
|
||||||
|
ConfigType = EConfigType.Trojan,
|
||||||
|
Remarks = "trojan demo",
|
||||||
|
Address = "trojan.example",
|
||||||
|
Port = 443,
|
||||||
|
Password = "trojan-pass",
|
||||||
|
Network = nameof(ETransport.raw),
|
||||||
|
StreamSecurity = Global.StreamSecurity,
|
||||||
|
Sni = "sni.trojan.example",
|
||||||
|
AllowInsecure = Global.StringTrue,
|
||||||
|
};
|
||||||
|
|
||||||
|
item.SetProtocolExtra(new ProtocolExtraItem { Flow = Global.Flows[1], });
|
||||||
|
item.SetTransportExtra(new TransportExtraItem { RawHeaderType = Global.None, });
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ProfileItem CreateTuicProfile()
|
||||||
|
{
|
||||||
|
var item = new ProfileItem
|
||||||
|
{
|
||||||
|
ConfigType = EConfigType.TUIC,
|
||||||
|
Remarks = "tuic demo",
|
||||||
|
Address = "tuic.example",
|
||||||
|
Port = 8443,
|
||||||
|
// A colon separates the two halves of the TUIC user info, so it cannot appear in the
|
||||||
|
// uuid; a fixed value also keeps a failure reproducible.
|
||||||
|
Username = "01234567-89ab-cdef-0123-456789abcdef",
|
||||||
|
Password = "tuic-pass",
|
||||||
|
Sni = "sni.tuic.example",
|
||||||
|
Alpn = "h3",
|
||||||
|
AllowInsecure = Global.StringTrue,
|
||||||
|
};
|
||||||
|
|
||||||
|
item.SetProtocolExtra(new ProtocolExtraItem { CongestionControl = "bbr", });
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ProfileItem CreateAnytlsProfile()
|
||||||
|
{
|
||||||
|
var item = new ProfileItem
|
||||||
|
{
|
||||||
|
ConfigType = EConfigType.Anytls,
|
||||||
|
Remarks = "anytls demo",
|
||||||
|
Address = "anytls.example",
|
||||||
|
Port = 8443,
|
||||||
|
Password = "anytls-pass",
|
||||||
|
Network = nameof(ETransport.raw),
|
||||||
|
StreamSecurity = Global.StreamSecurity,
|
||||||
|
Sni = "sni.anytls.example",
|
||||||
|
Alpn = "h2,http/1.1",
|
||||||
|
AllowInsecure = Global.StringTrue,
|
||||||
|
};
|
||||||
|
|
||||||
|
item.SetTransportExtra(new TransportExtraItem { RawHeaderType = Global.None, });
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ProfileItem CreateHysteria2Profile()
|
||||||
|
{
|
||||||
|
// CertSha is deliberately left unset: the importer turns AllowInsecure on by itself when a
|
||||||
|
// pinSHA256 is present, which would mask an exporter that stopped emitting insecure=1.
|
||||||
|
var item = new ProfileItem
|
||||||
|
{
|
||||||
|
ConfigType = EConfigType.Hysteria2,
|
||||||
|
Remarks = "hysteria2 demo",
|
||||||
|
Address = "hy2.example",
|
||||||
|
Port = 8443,
|
||||||
|
Password = "demo-user:demo-pass",
|
||||||
|
Sni = "sni.hy2.example",
|
||||||
|
Alpn = "h3",
|
||||||
|
EchConfigList = "AAj+DQAEAAAAAA==",
|
||||||
|
AllowInsecure = Global.StringTrue,
|
||||||
|
};
|
||||||
|
|
||||||
|
item.SetProtocolExtra(new ProtocolExtraItem
|
||||||
|
{
|
||||||
|
SalamanderPass = "salamander-pass",
|
||||||
|
Ports = "5000:6000",
|
||||||
|
});
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string CreateWireguardKey(byte value)
|
||||||
|
{
|
||||||
|
return Convert.ToBase64String(Enumerable.Repeat(value, 32).ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ProfileItem CreateWireguardProfile()
|
||||||
|
{
|
||||||
|
var item = new ProfileItem
|
||||||
|
{
|
||||||
|
ConfigType = EConfigType.WireGuard,
|
||||||
|
Remarks = "WireGuard — тест 東京 #1",
|
||||||
|
Address = "2001:db8::40",
|
||||||
|
Port = 51820,
|
||||||
|
Password = CreateWireguardKey(0xFE),
|
||||||
|
};
|
||||||
|
|
||||||
|
item.SetProtocolExtra(new ProtocolExtraItem
|
||||||
|
{
|
||||||
|
WgPublicKey = CreateWireguardKey(0xFD),
|
||||||
|
WgPresharedKey = CreateWireguardKey(0xFC),
|
||||||
|
WgReserved = "1,2,255",
|
||||||
|
WgInterfaceAddress = "10.0.0.2/32,fd00::2/128",
|
||||||
|
WgMtu = 1420,
|
||||||
|
});
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ProfileItem CreateNaiveProfile(bool quic)
|
||||||
|
{
|
||||||
|
var item = new ProfileItem
|
||||||
|
{
|
||||||
|
ConfigType = EConfigType.Naive,
|
||||||
|
Remarks = quic ? "naive quic demo" : "naive https demo",
|
||||||
|
Address = "naive.example",
|
||||||
|
Port = 443,
|
||||||
|
Username = "naive-user",
|
||||||
|
Password = "päss:word@/?#&=+ 東京",
|
||||||
|
Network = nameof(ETransport.raw),
|
||||||
|
StreamSecurity = Global.None,
|
||||||
|
};
|
||||||
|
|
||||||
|
item.SetProtocolExtra(new ProtocolExtraItem { NaiveQuic = quic, InsecureConcurrency = 4, });
|
||||||
|
item.SetTransportExtra(new TransportExtraItem { RawHeaderType = Global.None, });
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,27 @@
|
|||||||
using AwesomeAssertions;
|
|
||||||
using ServiceLib.Handler.Fmt;
|
|
||||||
using ServiceLib.Models.Dto;
|
|
||||||
using Xunit;
|
|
||||||
|
|
||||||
namespace ServiceLib.Tests.Fmt;
|
namespace ServiceLib.Tests.Fmt;
|
||||||
|
|
||||||
public class HyRealmTests
|
public class HyRealmTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Test]
|
||||||
public void TryParse_ShouldParseValidRealm()
|
public async Task TryParse_ShouldParseValidRealm()
|
||||||
{
|
{
|
||||||
var str = "realm://public@realm.hy2.io/57f9be7c-2810-4f5b-8cb9-260bc84d6c90?stun=example.stun:3478&stun=example2.stun:3478";
|
var str = "realm://public@realm.hy2.io/57f9be7c-2810-4f5b-8cb9-260bc84d6c90?stun=example.stun:3478&stun=example2.stun:3478";
|
||||||
var result = HyRealm.TryParse(str, out var realm);
|
var result = HyRealm.TryParse(str, out var realm);
|
||||||
result.Should().BeTrue();
|
await result.Should().BeTrue();
|
||||||
realm.Should().NotBeNull();
|
await realm.Should().NotBeNull();
|
||||||
|
|
||||||
realm.IsHttp.Should().BeFalse();
|
await realm.IsHttp.Should().BeFalse();
|
||||||
realm.Token.Should().Be("public");
|
await realm.Token.Should().BeEqualTo("public");
|
||||||
realm.RendezvousHost.Should().Be("realm.hy2.io");
|
await realm.RendezvousHost.Should().BeEqualTo("realm.hy2.io");
|
||||||
realm.RendezvousPort.Should().Be(443);
|
await realm.RendezvousPort.Should().BeEqualTo(443);
|
||||||
realm.RealmName.Should().Be("57f9be7c-2810-4f5b-8cb9-260bc84d6c90");
|
await realm.RealmName.Should().BeEqualTo("57f9be7c-2810-4f5b-8cb9-260bc84d6c90");
|
||||||
realm.StunList.Should().HaveCount(2);
|
await realm.StunList.Should().HaveCount(2);
|
||||||
realm.StunList.Should().Contain("example.stun:3478");
|
await realm.StunList.Should().Contain("example.stun:3478");
|
||||||
realm.StunList.Should().Contain("example2.stun:3478");
|
await realm.StunList.Should().Contain("example2.stun:3478");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Test]
|
||||||
public void ToUri_ShouldGenerateValidUri()
|
public async Task ToUri_ShouldGenerateValidUri()
|
||||||
{
|
{
|
||||||
var realm = new HyRealm(
|
var realm = new HyRealm(
|
||||||
IsHttp: false,
|
IsHttp: false,
|
||||||
@@ -37,32 +32,32 @@ public class HyRealmTests
|
|||||||
StunList: ["example.stun:3478", "example2.stun:3478"]
|
StunList: ["example.stun:3478", "example2.stun:3478"]
|
||||||
);
|
);
|
||||||
var uri = realm.ToUri();
|
var uri = realm.ToUri();
|
||||||
uri.Should().Contain("realm://public@realm.hy2.io");
|
await uri.Should().Contain("realm://public@realm.hy2.io");
|
||||||
uri.Should().Contain("/57f9be7c-2810-4f5b-8cb9-260bc84d6c90");
|
await uri.Should().Contain("/57f9be7c-2810-4f5b-8cb9-260bc84d6c90");
|
||||||
uri.Should().Contain("stun=example.stun:3478");
|
await uri.Should().Contain("stun=example.stun:3478");
|
||||||
uri.Should().Contain("stun=example2.stun:3478");
|
await uri.Should().Contain("stun=example2.stun:3478");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Test]
|
||||||
public void GetShareUriAndResolveConfig_Hy2Realm_ShouldRoundTripBasicFields()
|
public async Task GetShareUriAndResolveConfig_Hy2Realm_ShouldRoundTripBasicFields()
|
||||||
{
|
{
|
||||||
var str = "hysteria2+realm://mytoken@rendezvous.example.com/my-cabin-1f3a8c2e9b?auth=your_password&insecure=1&pinSHA256=deadbeef#remark";
|
var str = "hysteria2+realm://mytoken@rendezvous.example.com/my-cabin-1f3a8c2e9b?auth=your_password&insecure=1&pinSHA256=deadbeef#remark";
|
||||||
var resolved = Hysteria2Fmt.ResolveRealm(str, out var msg);
|
var resolved = Hysteria2Fmt.ResolveRealm(str, out var msg);
|
||||||
resolved.Should().NotBeNull();
|
await resolved.Should().NotBeNull();
|
||||||
resolved.Password.Should().Be("your_password");
|
await resolved.Password.Should().BeEqualTo("your_password");
|
||||||
var result = HyRealm.TryParse(resolved.GetProtocolExtra().Hy2RealmUrl, out var realm);
|
var result = HyRealm.TryParse(resolved.GetProtocolExtra().Hy2RealmUrl, out var realm);
|
||||||
result.Should().BeTrue();
|
await result.Should().BeTrue();
|
||||||
realm.Should().NotBeNull();
|
await realm.Should().NotBeNull();
|
||||||
realm.Token.Should().Be("mytoken");
|
await realm.Token.Should().BeEqualTo("mytoken");
|
||||||
|
|
||||||
// To uri
|
// To uri
|
||||||
var uri = Hysteria2Fmt.ToUri(resolved);
|
var uri = Hysteria2Fmt.ToUri(resolved);
|
||||||
uri.Should().Contain("hysteria2+realm://mytoken@rendezvous.example.com");
|
await uri.Should().Contain("hysteria2+realm://mytoken@rendezvous.example.com");
|
||||||
uri.Should().EndWith("#remark");
|
await uri.Should().EndWith("#remark");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Test]
|
||||||
public void ToServerUrl_ShouldIncludeSchemeForSingbox()
|
public async Task ToServerUrl_ShouldIncludeSchemeForSingbox()
|
||||||
{
|
{
|
||||||
var realm = new HyRealm(
|
var realm = new HyRealm(
|
||||||
IsHttp: false,
|
IsHttp: false,
|
||||||
@@ -73,19 +68,19 @@ public class HyRealmTests
|
|||||||
StunList: ["turn.cloudflare.com:3478"]
|
StunList: ["turn.cloudflare.com:3478"]
|
||||||
);
|
);
|
||||||
|
|
||||||
realm.ToServerUrl().Should().Be("https://realm.hy2.io:443");
|
await realm.ToServerUrl().Should().BeEqualTo("https://realm.hy2.io:443");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Test]
|
||||||
public void ResolveRealm_Issue9635_ShouldProduceHttpsServerUrl()
|
public async Task ResolveRealm_Issue9635_ShouldProduceHttpsServerUrl()
|
||||||
{
|
{
|
||||||
var str = "hysteria2+realm://public@realm.hy2.io/my-realm-id?auth=uuid&stun=turn.cloudflare.com%3A3478&sni=cloudflare.com&pinSHA256=xxx#Realm-Test";
|
var str = "hysteria2+realm://public@realm.hy2.io/my-realm-id?auth=uuid&stun=turn.cloudflare.com%3A3478&sni=cloudflare.com&pinSHA256=xxx#Realm-Test";
|
||||||
var resolved = Hysteria2Fmt.ResolveRealm(str, out _);
|
var resolved = Hysteria2Fmt.ResolveRealm(str, out _);
|
||||||
resolved.Should().NotBeNull();
|
await resolved.Should().NotBeNull();
|
||||||
|
|
||||||
HyRealm.TryParse(resolved!.GetProtocolExtra().Hy2RealmUrl, out var realm).Should().BeTrue();
|
await HyRealm.TryParse(resolved!.GetProtocolExtra().Hy2RealmUrl, out var realm).Should().BeTrue();
|
||||||
realm!.ToServerUrl().Should().StartWith("https://");
|
await realm!.ToServerUrl().Should().StartWith("https://");
|
||||||
realm.ToServerUrl().Should().Contain("realm.hy2.io");
|
await realm.ToServerUrl().Should().Contain("realm.hy2.io");
|
||||||
realm.StunList.Should().Contain("turn.cloudflare.com:3478");
|
await realm.StunList.Should().Contain("turn.cloudflare.com:3478");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
namespace ServiceLib.Tests.Fmt;
|
||||||
|
|
||||||
|
public class Hysteria2FmtTests
|
||||||
|
{
|
||||||
|
// "The hostname and optional port of the server. If the port is omitted, it defaults to 443."
|
||||||
|
// -- https://v2.hysteria.network/docs/developers/URI-Scheme/
|
||||||
|
// A ':' with no digits after it is an omitted port too, per RFC 3986 'port = *DIGIT'.
|
||||||
|
[Test]
|
||||||
|
[Arguments("hysteria2://password@hy2.example/")]
|
||||||
|
[Arguments("hysteria2://password@hy2.example")]
|
||||||
|
[Arguments("hysteria2://password@hy2.example:/")]
|
||||||
|
[Arguments("hy2://password@hy2.example/?sni=real.example")]
|
||||||
|
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.Hysteria2);
|
||||||
|
await resolved.Address.Should().BeEqualTo("hy2.example");
|
||||||
|
await resolved.Port.Should().BeEqualTo(443);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task ResolveConfig_WithoutPort_ShouldProduceAValidProfile()
|
||||||
|
{
|
||||||
|
// Uri.Port is -1 for an unregistered scheme with no port, and ProfileItem.IsValid rejects
|
||||||
|
// any port outside 1..65535 - so the default is what keeps such a link usable at all.
|
||||||
|
var resolved = FmtHandler.ResolveConfig("hysteria2://password@hy2.example/", out _);
|
||||||
|
|
||||||
|
await resolved.Should().NotBeNull();
|
||||||
|
await resolved!.IsValid().Should().BeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task ResolveConfig_WithExplicitPort_ShouldKeepIt()
|
||||||
|
{
|
||||||
|
var resolved = FmtHandler.ResolveConfig("hysteria2://password@hy2.example:8443/", out _);
|
||||||
|
|
||||||
|
await resolved.Should().NotBeNull();
|
||||||
|
await resolved!.Port.Should().BeEqualTo(8443);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task ResolveConfig_WithExplicitZeroPort_ShouldNotApplyTheDefault()
|
||||||
|
{
|
||||||
|
// Uri.Port is 0 here, not -1: the port is present, it is just not a usable one. Treating
|
||||||
|
// it as "omitted" would silently move the endpoint to :443, so it stays invalid instead.
|
||||||
|
var resolved = FmtHandler.ResolveConfig("hysteria2://password@hy2.example:0/", out _);
|
||||||
|
|
||||||
|
await resolved.Should().NotBeNull();
|
||||||
|
await resolved!.Port.Should().BeEqualTo(0);
|
||||||
|
await resolved.IsValid().Should().BeFalse();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,11 @@
|
|||||||
using AwesomeAssertions;
|
|
||||||
using ServiceLib.Enums;
|
|
||||||
using ServiceLib.Handler.Fmt;
|
|
||||||
using ServiceLib.Tests.CoreConfig;
|
using ServiceLib.Tests.CoreConfig;
|
||||||
using Xunit;
|
|
||||||
|
|
||||||
namespace ServiceLib.Tests.Fmt;
|
namespace ServiceLib.Tests.Fmt;
|
||||||
|
|
||||||
public class InnerFmtTests
|
public class InnerFmtTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Test]
|
||||||
public void ToUriAndResolve_ShouldRoundTripPolicyGroupReferences()
|
public async Task ToUriAndResolve_ShouldRoundTripPolicyGroupReferences()
|
||||||
{
|
{
|
||||||
var childA = CoreConfigTestFactory.CreateSocksNode(ECoreType.Xray, "child-a", "child-a");
|
var childA = CoreConfigTestFactory.CreateSocksNode(ECoreType.Xray, "child-a", "child-a");
|
||||||
var childB = CoreConfigTestFactory.CreateVmessNode(ECoreType.Xray, "child-b", "child-b");
|
var childB = CoreConfigTestFactory.CreateVmessNode(ECoreType.Xray, "child-b", "child-b");
|
||||||
@@ -19,19 +15,20 @@ public class InnerFmtTests
|
|||||||
|
|
||||||
var uri = InnerFmt.ToUri([group, childA, childB]);
|
var uri = InnerFmt.ToUri([group, childA, childB]);
|
||||||
|
|
||||||
uri.Should().NotBeNullOrWhiteSpace();
|
await uri.Should().NotBeNull();
|
||||||
|
await uri.Should().NotBeEmpty();
|
||||||
|
|
||||||
var resolved = InnerFmt.Resolve(uri!, "sub-123");
|
var resolved = InnerFmt.Resolve(uri!, "sub-123");
|
||||||
|
|
||||||
resolved.Should().NotBeNull();
|
await resolved.Should().NotBeNull();
|
||||||
resolved.Should().HaveCount(3);
|
await resolved.Should().HaveCount(3);
|
||||||
|
|
||||||
var resolvedGroup = resolved!.Single(x => x.Remarks == group.Remarks);
|
var resolvedGroup = resolved!.Single(x => x.Remarks == group.Remarks);
|
||||||
var resolvedChildA = resolved.Single(x => x.Remarks == childA.Remarks);
|
var resolvedChildA = resolved.Single(x => x.Remarks == childA.Remarks);
|
||||||
var resolvedChildB = resolved.Single(x => x.Remarks == childB.Remarks);
|
var resolvedChildB = resolved.Single(x => x.Remarks == childB.Remarks);
|
||||||
|
|
||||||
resolvedGroup.ConfigType.Should().Be(EConfigType.PolicyGroup);
|
await resolvedGroup.ConfigType.Should().BeEqualTo(EConfigType.PolicyGroup);
|
||||||
resolvedGroup.GetProtocolExtra().SubChildItems.Should().Be("sub-123");
|
await resolvedGroup.GetProtocolExtra().SubChildItems.Should().BeEqualTo("sub-123");
|
||||||
resolvedGroup.GetProtocolExtra().ChildItems.Should().Be($"{resolvedChildA.IndexId},{resolvedChildB.IndexId}");
|
await resolvedGroup.GetProtocolExtra().ChildItems.Should().BeEqualTo($"{resolvedChildA.IndexId},{resolvedChildB.IndexId}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
namespace ServiceLib.Tests.Fmt;
|
||||||
|
|
||||||
|
public class ShareUriQueryTests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
[Arguments("ob%41fs")]
|
||||||
|
[Arguments("66%ff")]
|
||||||
|
[Arguments("100%")]
|
||||||
|
public async Task GetShareUriAndResolveConfig_QueryValueWithPercent_ShouldSurviveTheRoundTrip(string obfsPassword)
|
||||||
|
{
|
||||||
|
var source = new ProfileItem
|
||||||
|
{
|
||||||
|
ConfigType = EConfigType.Hysteria2,
|
||||||
|
Remarks = "percent demo",
|
||||||
|
Address = "hy2.example",
|
||||||
|
Port = 8443,
|
||||||
|
Password = "pw",
|
||||||
|
};
|
||||||
|
source.SetProtocolExtra(new ProtocolExtraItem { SalamanderPass = obfsPassword, });
|
||||||
|
|
||||||
|
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!.GetProtocolExtra().SalamanderPass.Should().BeEqualTo(obfsPassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
// RFC 3986 lists '=' among the sub-delimiters a query value may carry, so only the first one
|
||||||
|
// separates the key from the value. Splitting on every '=' discarded the pair outright.
|
||||||
|
[Test]
|
||||||
|
public async Task ResolveConfig_QueryValueWithUnescapedEquals_ShouldNotBeDropped()
|
||||||
|
{
|
||||||
|
const string shareUri = "hysteria2://pw@hy2.example:8443/?ech=AAj+DQAEAAAAAA==&sni=real.example";
|
||||||
|
|
||||||
|
var resolved = FmtHandler.ResolveConfig(shareUri, out var msg);
|
||||||
|
|
||||||
|
await resolved.Should().NotBeNull().Because($"uri: {shareUri}, msg: {msg}");
|
||||||
|
await resolved!.EchConfigList.Should().BeEqualTo("AAj+DQAEAAAAAA==");
|
||||||
|
await resolved.Sni.Should().BeEqualTo("real.example");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Canonical SIP002 percent-encodes the plugin argument, and that is what this client's own
|
||||||
|
// exporter emits. This covers the non-canonical spelling instead: the options are a ';'
|
||||||
|
// separated list of 'key=value' pairs, so left unescaped the value always carries '='.
|
||||||
|
[Test]
|
||||||
|
public async Task ResolveConfig_NonCanonicalSip002PluginWithLiteralEquals_ShouldConfigureObfs()
|
||||||
|
{
|
||||||
|
const string shareUri =
|
||||||
|
"ss://YWVzLTEyOC1nY206cGFzczEyMw==@1.2.3.4:8388/?plugin=obfs-local;obfs=http;obfs-host=example.com#ss";
|
||||||
|
|
||||||
|
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.GetTransportExtra().Host.Should().BeEqualTo("example.com");
|
||||||
|
await resolved.GetTransportExtra().RawHeaderType.Should().BeEqualTo(Global.RawHeaderHttp);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task ResolveConfig_EscapedQueryValue_ShouldStillDecodeExactlyOnce()
|
||||||
|
{
|
||||||
|
const string shareUri = "hysteria2://pw@hy2.example:8443/?ech=AAj%2BDQAEAAAAAA%3D%3D&obfs=salamander&obfs-password=a%20b";
|
||||||
|
|
||||||
|
var resolved = FmtHandler.ResolveConfig(shareUri, out var msg);
|
||||||
|
|
||||||
|
await resolved.Should().NotBeNull().Because($"uri: {shareUri}, msg: {msg}");
|
||||||
|
await resolved!.EchConfigList.Should().BeEqualTo("AAj+DQAEAAAAAA==");
|
||||||
|
await resolved.GetProtocolExtra().SalamanderPass.Should().BeEqualTo("a b");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,9 @@
|
|||||||
using AwesomeAssertions;
|
|
||||||
using ServiceLib.Handler.Fmt;
|
|
||||||
using Xunit;
|
|
||||||
|
|
||||||
namespace ServiceLib.Tests.Fmt;
|
namespace ServiceLib.Tests.Fmt;
|
||||||
|
|
||||||
public class WireguardFmtTests
|
public class WireguardFmtTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Test]
|
||||||
public void ResolveConfig_ShouldParsePeersAndIgnoreInlineComments()
|
public async Task ResolveConfig_ShouldParsePeersAndIgnoreInlineComments()
|
||||||
{
|
{
|
||||||
const string config =
|
const string config =
|
||||||
"""
|
"""
|
||||||
@@ -15,6 +11,7 @@ public class WireguardFmtTests
|
|||||||
PrivateKey = interface-private-key
|
PrivateKey = interface-private-key
|
||||||
Address = 10.0.0.2/32, fd00::2/128 ; inline comment
|
Address = 10.0.0.2/32, fd00::2/128 ; inline comment
|
||||||
MTU = 1420
|
MTU = 1420
|
||||||
|
DNS = 2001::db8::53, 1.2.3.4, 2001:db8::54
|
||||||
|
|
||||||
[Peer]
|
[Peer]
|
||||||
PublicKey = peer-public-key
|
PublicKey = peer-public-key
|
||||||
@@ -29,19 +26,20 @@ public class WireguardFmtTests
|
|||||||
|
|
||||||
var resolved = WireguardFmt.ResolveConfig(config);
|
var resolved = WireguardFmt.ResolveConfig(config);
|
||||||
|
|
||||||
resolved.Should().NotBeNull();
|
await resolved.Should().NotBeNull();
|
||||||
resolved.Should().HaveCount(2);
|
await resolved.Should().HaveCount(2);
|
||||||
|
|
||||||
var first = resolved![0];
|
var first = resolved![0];
|
||||||
first.Address.Should().Be("2001:db8::1");
|
await first.Address.Should().BeEqualTo("2001:db8::1");
|
||||||
first.Port.Should().Be(51820);
|
await first.Port.Should().BeEqualTo(51820);
|
||||||
first.Password.Should().Be("interface-private-key");
|
await first.Password.Should().BeEqualTo("interface-private-key");
|
||||||
first.GetProtocolExtra().WgReserved.Should().Be("1, 2, 3");
|
await first.GetProtocolExtra().WgReserved.Should().BeEqualTo("1, 2, 3");
|
||||||
first.GetProtocolExtra().WgInterfaceAddress.Should().Be("10.0.0.2/32, fd00::2/128");
|
await first.GetProtocolExtra().WgInterfaceAddress.Should().BeEqualTo("10.0.0.2/32, fd00::2/128");
|
||||||
first.GetProtocolExtra().WgMtu.Should().Be(1420);
|
await first.GetProtocolExtra().WgMtu.Should().BeEqualTo(1420);
|
||||||
|
await first.GetProtocolExtra().WgDns.Should().BeEqualTo("2001::db8::53, 1.2.3.4, 2001:db8::54");
|
||||||
|
|
||||||
var second = resolved[1];
|
var second = resolved[1];
|
||||||
second.Address.Should().Be("example.com");
|
await second.Address.Should().BeEqualTo("example.com");
|
||||||
second.Port.Should().Be(12345);
|
await second.Port.Should().BeEqualTo(12345);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,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 System.Reactive;
|
|
||||||
global using System.Reactive.Disposables;
|
|
||||||
global using System.Reactive.Linq;
|
|
||||||
global using System.Reflection;
|
global using System.Reflection;
|
||||||
global using System.Runtime.InteropServices;
|
global using System.Runtime.InteropServices;
|
||||||
global using System.Security.Cryptography;
|
global using System.Security.Cryptography;
|
||||||
@@ -15,10 +12,7 @@ global using System.Text.Json;
|
|||||||
global using System.Text.Json.Nodes;
|
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 DynamicData;
|
|
||||||
global using DynamicData.Binding;
|
|
||||||
global using ReactiveUI;
|
global using ReactiveUI;
|
||||||
global using ReactiveUI.Fody.Helpers;
|
|
||||||
global using ServiceLib.Base;
|
global using ServiceLib.Base;
|
||||||
global using ServiceLib.Common;
|
global using ServiceLib.Common;
|
||||||
global using ServiceLib.Enums;
|
global using ServiceLib.Enums;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
await database.Find<SubItem>(item.Id).RequestHeaders.Should().BeEqualTo(item.RequestHeaders);
|
||||||
|
|
||||||
|
item.RequestHeaders = "";
|
||||||
|
database.Update(item);
|
||||||
|
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,39 @@
|
|||||||
|
namespace ServiceLib.Tests.Manager;
|
||||||
|
|
||||||
|
public class CoreManagerTests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
[Arguments(ECoreType.sing_box)]
|
||||||
|
[Arguments(ECoreType.mihomo)]
|
||||||
|
[Arguments(ECoreType.Xray)]
|
||||||
|
public async Task ShouldRunAsSudo_TunLaunchOnNonWindows_RequiresElevation(ECoreType coreType)
|
||||||
|
{
|
||||||
|
await CoreManager.ShouldRunAsSudo(isTunLaunch: true, coreType, isNonWindows: true).Should().BeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task ShouldRunAsSudo_NonTunLaunch_ShouldNotElevate()
|
||||||
|
{
|
||||||
|
// Regression guard for the macOS TUN failure: the elevation decision must follow
|
||||||
|
// the context snapshot that generated the config. A launch whose snapshot has TUN
|
||||||
|
// disabled must never elevate, and a launch whose snapshot has TUN enabled must
|
||||||
|
// elevate regardless of later changes to the live config.
|
||||||
|
await CoreManager.ShouldRunAsSudo(isTunLaunch: false, ECoreType.sing_box, isNonWindows: true).Should().BeFalse();
|
||||||
|
await CoreManager.ShouldRunAsSudo(isTunLaunch: false, ECoreType.Xray, isNonWindows: true).Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task ShouldRunAsSudo_OnWindows_ShouldNotElevate()
|
||||||
|
{
|
||||||
|
await CoreManager.ShouldRunAsSudo(isTunLaunch: true, ECoreType.sing_box, isNonWindows: false).Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
[Arguments(ECoreType.v2fly)]
|
||||||
|
[Arguments(ECoreType.hysteria)]
|
||||||
|
[Arguments(null)]
|
||||||
|
public async Task ShouldRunAsSudo_UnsupportedCoreType_ShouldNotElevate(ECoreType? coreType)
|
||||||
|
{
|
||||||
|
await CoreManager.ShouldRunAsSudo(isTunLaunch: true, coreType, isNonWindows: true).Should().BeFalse();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,23 +1,23 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<OutputType>Exe</OutputType>
|
<OutputType>Exe</OutputType>
|
||||||
<IsPackable>false</IsPackable>
|
<IsPackable>false</IsPackable>
|
||||||
<IsTestProject>true</IsTestProject>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="AwesomeAssertions" />
|
<PackageReference Include="TUnit" />
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
<PackageReference Include="TUnit.Assertions.Should" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio">
|
</ItemGroup>
|
||||||
<PrivateAssets>all</PrivateAssets>
|
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
|
||||||
</PackageReference>
|
|
||||||
<PackageReference Include="xunit.v3" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\ServiceLib\ServiceLib.csproj" />
|
<ProjectReference Include="..\ServiceLib\ServiceLib.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="TUnit.Assertions.Should" />
|
||||||
|
<Using Include="TUnit.Assertions.Should.Extensions" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
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();
|
||||||
|
var service = new DownloadService { AcceptHeader = "*/*", RequestHeaders = headers };
|
||||||
|
var uri = new UriBuilder(useProxy ? "http://subscription.invalid/sub" : server.Url)
|
||||||
|
{
|
||||||
|
UserName = "user",
|
||||||
|
Password = "password"
|
||||||
|
}.Uri;
|
||||||
|
IWebProxy? proxy = useProxy ? new WebProxy(server.Url) : null;
|
||||||
|
|
||||||
|
var content = await service.TryDownloadString(uri.AbsoluteUri, proxy, "OriginalClient/1.0").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);
|
||||||
|
var service = new DownloadService { AcceptHeader = "*/*" };
|
||||||
|
var uri = new UriBuilder(server.Url) { UserName = "user", Password = "password" }.Uri;
|
||||||
|
|
||||||
|
var content = await service.TryDownloadString(uri.AbsoluteUri, (IWebProxy?)null, "ExistingClient/1.0").WaitAsync(TimeSpan.FromSeconds(20));
|
||||||
|
|
||||||
|
await content.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 subscription = new DownloadService
|
||||||
|
{
|
||||||
|
AcceptHeader = "*/*",
|
||||||
|
RequestHeaders = new Dictionary<string, string> { ["X-hwid"] = "first-device" }
|
||||||
|
};
|
||||||
|
var ordinaryDownload = new DownloadService();
|
||||||
|
|
||||||
|
await (await subscription.TryDownloadString(server.Url, (IWebProxy?)null, "TestClient/1.0")).Should().BeEqualTo(SubscriptionHttpServer.Body);
|
||||||
|
await (await ordinaryDownload.TryDownloadString(server.Url, (IWebProxy?)null, "TestClient/1.0")).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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,19 +24,20 @@ 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>
|
||||||
/// Send UDP data to a remote endpoint (domain name or IP address)
|
/// Send UDP data to a remote endpoint (domain name or IP address)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <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,11 +59,12 @@ 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>
|
||||||
/// Receive UDP data from remote endpoint
|
/// Receive UDP data from remote endpoint
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="cancellationToken">Cancellation token to cancel the receive operation</param>
|
/// <param name="cancellationToken">Cancellation token to cancel the receive operation</param>
|
||||||
/// <returns>Remote endpoint information and received data</returns>
|
/// <returns>Remote endpoint information and received data</returns>
|
||||||
@@ -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,6 +135,10 @@ public class UdpTestService
|
|||||||
roundTripTime = currentRoundTripTime;
|
roundTripTime = currentRoundTripTime;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
if (attempt == 1 && roundTripTime == TimeSpan.MaxValue)
|
if (attempt == 1 && roundTripTime == TimeSpan.MaxValue)
|
||||||
@@ -137,18 +148,10 @@ public class UdpTestService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((udpReceiveResult?.Length ?? 0) < 4 + 1 + 4 + 2)
|
if (validUdpReceiveResult != null)
|
||||||
{
|
|
||||||
throw new Exception("Received NTP response is too short.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (udpReceiveResult != null && _udpTest.VerifyAndExtractUdpResponse(udpReceiveResult))
|
|
||||||
{
|
{
|
||||||
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.");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
namespace ServiceLib.Base;
|
||||||
|
|
||||||
|
public class BulkObservableCollection<T> : ObservableCollection<T>
|
||||||
|
{
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
if (!_suppressNotification)
|
||||||
|
{
|
||||||
|
base.OnCollectionChanged(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnPropertyChanged(PropertyChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (!_suppressNotification)
|
||||||
|
{
|
||||||
|
base.OnPropertyChanged(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AddRange(IEnumerable<T>? collection)
|
||||||
|
{
|
||||||
|
if (collection == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_suppressNotification = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var item in collection)
|
||||||
|
{
|
||||||
|
Add(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_suppressNotification = false;
|
||||||
|
OnPropertyChanged(new PropertyChangedEventArgs(nameof(Count)));
|
||||||
|
OnPropertyChanged(new PropertyChangedEventArgs("Item[]"));
|
||||||
|
OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Replace(T oldItem, T newItem)
|
||||||
|
{
|
||||||
|
var index = Items.IndexOf(oldItem);
|
||||||
|
if (index < 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Items[index] = newItem;
|
||||||
|
|
||||||
|
OnPropertyChanged(new PropertyChangedEventArgs("Item[]"));
|
||||||
|
OnCollectionChanged(new NotifyCollectionChangedEventArgs(
|
||||||
|
NotifyCollectionChangedAction.Replace,
|
||||||
|
newItem,
|
||||||
|
oldItem,
|
||||||
|
index));
|
||||||
|
|
||||||
|
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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
namespace ServiceLib.Common;
|
namespace ServiceLib.Common;
|
||||||
|
|
||||||
@@ -92,7 +93,7 @@ public static class Extension
|
|||||||
|
|
||||||
public static bool IsComplexType(this EConfigType configType)
|
public static bool IsComplexType(this EConfigType configType)
|
||||||
{
|
{
|
||||||
return configType is EConfigType.Custom or EConfigType.PolicyGroup or EConfigType.ProxyChain;
|
return configType is EConfigType.Custom or EConfigType.Outbound or EConfigType.PolicyGroup or EConfigType.ProxyChain;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -134,4 +135,24 @@ public static class Extension
|
|||||||
.Replace("\r", replacement)
|
.Replace("\r", replacement)
|
||||||
.Replace("\n", replacement);
|
.Replace("\n", replacement);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static IObservable<TOutput> HandleSafe<TInput, TOutput>(
|
||||||
|
this Interaction<TInput, TOutput> interaction, TInput input,
|
||||||
|
TOutput defaultValue = default!,
|
||||||
|
[CallerMemberName] string memberName = "",
|
||||||
|
[CallerFilePath] string filePath = "",
|
||||||
|
[CallerLineNumber] int lineNumber = 0)
|
||||||
|
{
|
||||||
|
return Signal.Defer(() => interaction.Handle(input))
|
||||||
|
.Catch<TOutput, UnhandledInteractionException<TInput, TOutput>>(ex =>
|
||||||
|
{
|
||||||
|
Logging.SaveLog($"Unhandled interaction exception in {memberName} at {filePath}:{lineNumber}", ex);
|
||||||
|
return Signal.Return(defaultValue);
|
||||||
|
})
|
||||||
|
.Catch<TOutput, Exception>(ex =>
|
||||||
|
{
|
||||||
|
Logging.SaveLog($"Exception occurred while handling interaction in {memberName} at {filePath}:{lineNumber}, input: {input}", ex);
|
||||||
|
return Signal.Return(defaultValue);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
using System.Collections.Specialized;
|
|
||||||
using System.Security.Principal;
|
using System.Security.Principal;
|
||||||
using CliWrap;
|
using CliWrap;
|
||||||
using CliWrap.Buffered;
|
using CliWrap.Buffered;
|
||||||
@@ -201,7 +200,9 @@ public class Utils
|
|||||||
var parts = query[1..].Split('&', StringSplitOptions.RemoveEmptyEntries);
|
var parts = query[1..].Split('&', StringSplitOptions.RemoveEmptyEntries);
|
||||||
foreach (var part in parts)
|
foreach (var part in parts)
|
||||||
{
|
{
|
||||||
var keyValue = part.Split('=');
|
// Split on the FIRST '=' only: RFC 3986 lists '=' among the sub-delimiters a query
|
||||||
|
// value may carry, so everything after the first one belongs to the value.
|
||||||
|
var keyValue = part.Split('=', 2);
|
||||||
if (keyValue.Length != 2)
|
if (keyValue.Length != 2)
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
@@ -485,12 +486,17 @@ public class Utils
|
|||||||
|
|
||||||
public static string? DomainStrategy4Sbox(string? strategy)
|
public static string? DomainStrategy4Sbox(string? strategy)
|
||||||
{
|
{
|
||||||
|
if (strategy is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return strategy switch
|
return strategy switch
|
||||||
{
|
{
|
||||||
not null when strategy.StartsWith("UseIPv4") => "prefer_ipv4",
|
_ when strategy.StartsWith("UseIPv6") => "prefer_ipv6",
|
||||||
not null when strategy.StartsWith("UseIPv6") => "prefer_ipv6",
|
_ when strategy.StartsWith("UseIP") => "prefer_ipv4",
|
||||||
not null when strategy.StartsWith("ForceIPv4") => "ipv4_only",
|
_ when strategy.StartsWith("ForceIPv6") => "ipv6_only",
|
||||||
not null when strategy.StartsWith("ForceIPv6") => "ipv6_only",
|
_ when strategy.StartsWith("ForceIP") => "ipv4_only",
|
||||||
_ => null
|
_ => null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -720,6 +726,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
|
||||||
@@ -748,11 +788,11 @@ public class Utils
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static int GetFreePort(int defaultPort = 0)
|
public static int GetFreePort(int defaultPort)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (!(defaultPort == 0 || Utils.PortInUse(defaultPort)))
|
if (!PortInUse(defaultPort))
|
||||||
{
|
{
|
||||||
return defaultPort;
|
return defaultPort;
|
||||||
}
|
}
|
||||||
@@ -809,6 +849,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
|
||||||
@@ -978,12 +1052,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
|
||||||
{
|
{
|
||||||
@@ -1000,7 +1074,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 ?? "";
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ public enum EConfigType
|
|||||||
HTTP = 10,
|
HTTP = 10,
|
||||||
Anytls = 11,
|
Anytls = 11,
|
||||||
Naive = 12,
|
Naive = 12,
|
||||||
|
Outbound = 13,
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ namespace ServiceLib.Events;
|
|||||||
|
|
||||||
public static class AppEvents
|
public static class AppEvents
|
||||||
{
|
{
|
||||||
public static readonly EventChannel<Unit> AddServerViaClipboardRequested = new();
|
public static readonly EventChannel<RxVoid> AddServerViaClipboardRequested = new();
|
||||||
public static readonly EventChannel<bool> HasUpdateNotified = new();
|
public static readonly EventChannel<bool> HasUpdateNotified = new();
|
||||||
|
|
||||||
public static readonly EventChannel<ServerSpeedItem> DispatcherStatisticsRequested = new();
|
public static readonly EventChannel<ServerSpeedItem> DispatcherStatisticsRequested = new();
|
||||||
@@ -10,7 +10,7 @@ public static class AppEvents
|
|||||||
public static readonly EventChannel<string> SendSnackMsgRequested = new();
|
public static readonly EventChannel<string> SendSnackMsgRequested = new();
|
||||||
public static readonly EventChannel<string> SendMsgViewRequested = new();
|
public static readonly EventChannel<string> SendMsgViewRequested = new();
|
||||||
|
|
||||||
public static readonly EventChannel<Unit> AppExitRequested = new();
|
public static readonly EventChannel<RxVoid> AppExitRequested = new();
|
||||||
public static readonly EventChannel<bool> ShutdownRequested = new();
|
public static readonly EventChannel<bool> ShutdownRequested = new();
|
||||||
|
|
||||||
public static readonly EventChannel<ESysProxyType> SysProxyChangeRequested = new();
|
public static readonly EventChannel<ESysProxyType> SysProxyChangeRequested = new();
|
||||||
|
|||||||
@@ -1,27 +1,38 @@
|
|||||||
using System.Reactive.Subjects;
|
|
||||||
|
|
||||||
namespace ServiceLib.Events;
|
namespace ServiceLib.Events;
|
||||||
|
|
||||||
public sealed class EventChannel<T>
|
public sealed class EventChannel<T>
|
||||||
{
|
{
|
||||||
private readonly ISubject<T> _subject = Subject.Synchronize(new Subject<T>());
|
private readonly Signal<T> _signal = new();
|
||||||
|
private readonly Lock _gate = new();
|
||||||
|
private readonly IObservable<T> _observable;
|
||||||
|
|
||||||
|
public EventChannel()
|
||||||
|
{
|
||||||
|
_observable = _signal.Synchronize(_gate);
|
||||||
|
}
|
||||||
|
|
||||||
public IObservable<T> AsObservable()
|
public IObservable<T> AsObservable()
|
||||||
{
|
{
|
||||||
return _subject.AsObservable();
|
return _observable;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Publish(T value)
|
public void Publish(T value)
|
||||||
{
|
{
|
||||||
_subject.OnNext(value);
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_signal.OnNext(value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Publish()
|
public void Publish()
|
||||||
{
|
{
|
||||||
if (typeof(T) != typeof(Unit))
|
if (typeof(T) != typeof(RxVoid))
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException("Publish() without value is only valid for EventChannel<Unit>.");
|
throw new InvalidOperationException("Publish() without value is only valid for EventChannel<RxVoid>.");
|
||||||
|
}
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_signal.OnNext((T)(object)RxVoid.Default);
|
||||||
}
|
}
|
||||||
_subject.OnNext((T)(object)Unit.Default);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
|
|
||||||
<ReactiveUI />
|
|
||||||
</Weavers>
|
|
||||||
+61
-19
@@ -2,8 +2,6 @@ namespace ServiceLib;
|
|||||||
|
|
||||||
public class Global
|
public class Global
|
||||||
{
|
{
|
||||||
#region const
|
|
||||||
|
|
||||||
public const string AppName = "v2rayN";
|
public const string AppName = "v2rayN";
|
||||||
public const string GithubUrl = "https://github.com";
|
public const string GithubUrl = "https://github.com";
|
||||||
public const string GithubApiUrl = "https://api.github.com/repos";
|
public const string GithubApiUrl = "https://api.github.com/repos";
|
||||||
@@ -94,21 +92,30 @@ 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";
|
||||||
|
|
||||||
public const string PolicyGroupDefaultAllFilter = $"^(?!.*(?:{PolicyGroupExcludeKeywords})).*$";
|
public const string PolicyGroupDefaultAllFilter = $"^(?!.*(?:{PolicyGroupExcludeKeywords})).*$";
|
||||||
|
|
||||||
public static readonly List<string> PolicyGroupDefaultFilterList =
|
public static readonly List<string> PolicyGroupDefaultFilterList =
|
||||||
@@ -488,16 +495,22 @@ public class Global
|
|||||||
"localhost"
|
"localhost"
|
||||||
];
|
];
|
||||||
|
|
||||||
|
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 =
|
||||||
@@ -557,7 +570,6 @@ public class Global
|
|||||||
"http",
|
"http",
|
||||||
"tls",
|
"tls",
|
||||||
"quic",
|
"quic",
|
||||||
"fakedns",
|
|
||||||
];
|
];
|
||||||
|
|
||||||
public static readonly List<int> TunMtus =
|
public static readonly List<int> TunMtus =
|
||||||
@@ -737,6 +749,12 @@ public class Global
|
|||||||
"reply",
|
"reply",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
public static readonly List<string> FakeIPRanges =
|
||||||
|
[
|
||||||
|
"198.18.0.0/15",
|
||||||
|
"11.0.0.0/8",
|
||||||
|
];
|
||||||
|
|
||||||
public static readonly List<string> RootCertProviders =
|
public static readonly List<string> RootCertProviders =
|
||||||
[
|
[
|
||||||
"system",
|
"system",
|
||||||
@@ -744,5 +762,29 @@ public class Global
|
|||||||
MozillaRootProvider,
|
MozillaRootProvider,
|
||||||
];
|
];
|
||||||
|
|
||||||
#endregion const
|
public static readonly IReadOnlyList<string> TunIPv4Address =
|
||||||
|
[
|
||||||
|
"172.18.0.1/30",
|
||||||
|
"172.31.0.1/30",
|
||||||
|
"172.20.0.1/30",
|
||||||
|
"172.16.0.1/30",
|
||||||
|
"192.168.100.1/30",
|
||||||
|
"10.10.14.1/30",
|
||||||
|
"10.1.0.1/30",
|
||||||
|
"10.0.0.1/30",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Prefixes must leave room for a peer address (max /126); the sing-box system
|
||||||
|
// stack derives a gateway from the first prefix and rejects single-address prefixes.
|
||||||
|
public static readonly IReadOnlyList<string> TunIPv6Address =
|
||||||
|
[
|
||||||
|
"fc00::172:18:0:1/126",
|
||||||
|
"fc00::172:31:0:1/126",
|
||||||
|
"fc00::172:20:0:1/126",
|
||||||
|
"fc00::172:16:0:1/126",
|
||||||
|
"fc00::192:168:100:1/126",
|
||||||
|
"fc00::10:10:14:1/126",
|
||||||
|
"fc00::10:1:0:1/126",
|
||||||
|
"fc00::10:0:0:1/126",
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
global using System.Collections.Concurrent;
|
global using System.Collections.Concurrent;
|
||||||
|
global using System.Collections.ObjectModel;
|
||||||
|
global using System.Collections.Specialized;
|
||||||
|
global using System.ComponentModel;
|
||||||
global using System.Diagnostics;
|
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 System.Reactive;
|
|
||||||
global using System.Reactive.Disposables;
|
|
||||||
global using System.Reactive.Linq;
|
|
||||||
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;
|
||||||
@@ -16,10 +16,13 @@ global using System.Text.Json;
|
|||||||
global using System.Text.Json.Nodes;
|
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 DynamicData;
|
|
||||||
global using DynamicData.Binding;
|
|
||||||
global using ReactiveUI;
|
global using ReactiveUI;
|
||||||
global using ReactiveUI.Fody.Helpers;
|
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 ServiceLib.Base;
|
global using ServiceLib.Base;
|
||||||
global using ServiceLib.Common;
|
global using ServiceLib.Common;
|
||||||
global using ServiceLib.Enums;
|
global using ServiceLib.Enums;
|
||||||
@@ -30,8 +33,8 @@ global using ServiceLib.Handler.Fmt;
|
|||||||
global using ServiceLib.Handler.SysProxy;
|
global using ServiceLib.Handler.SysProxy;
|
||||||
global using ServiceLib.Helper;
|
global using ServiceLib.Helper;
|
||||||
global using ServiceLib.Manager;
|
global using ServiceLib.Manager;
|
||||||
global using ServiceLib.Models.CoreConfigs;
|
|
||||||
global using ServiceLib.Models.Configs;
|
global using ServiceLib.Models.Configs;
|
||||||
|
global using ServiceLib.Models.CoreConfigs;
|
||||||
global using ServiceLib.Models.Dto;
|
global using ServiceLib.Models.Dto;
|
||||||
global using ServiceLib.Models.Entities;
|
global using ServiceLib.Models.Entities;
|
||||||
global using ServiceLib.Resx;
|
global using ServiceLib.Resx;
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
HasGlobalIPv6Address = Utils.HasGlobalIPv6Address(),
|
||||||
|
ProtectCoreTypeList = config.TunModeItem.EnableTun ? [ECoreType.Xray, ECoreType.sing_box] : []
|
||||||
};
|
};
|
||||||
var validatorResult = NodeValidatorResult.Empty();
|
var validatorResult = NodeValidatorResult.Empty();
|
||||||
var (actNode, nodeValidatorResult) = await ResolveNodeAsync(context, node);
|
var (actNode, nodeValidatorResult) = await ResolveNodeAsync(context, node);
|
||||||
@@ -94,6 +96,7 @@ public class CoreConfigContextBuilder
|
|||||||
context.AllProxiesMap[$"remark:{ruleItem.OutboundTag}"] = actRuleNode;
|
context.AllProxiesMap[$"remark:{ruleItem.OutboundTag}"] = actRuleNode;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (context.IsTunEnabled && context.AppConfig.TunModeItem.RouteExcludeAddress is { Count: > 0 })
|
if (context.IsTunEnabled && context.AppConfig.TunModeItem.RouteExcludeAddress is { Count: > 0 })
|
||||||
{
|
{
|
||||||
var appConfig = JsonUtils.DeepCopy(config);
|
var appConfig = JsonUtils.DeepCopy(config);
|
||||||
@@ -201,7 +204,7 @@ public class CoreConfigContextBuilder
|
|||||||
Context = preSocksResult.Context with
|
Context = preSocksResult.Context with
|
||||||
{
|
{
|
||||||
ProtectDomainList =
|
ProtectDomainList =
|
||||||
[.. nodeContext.ProtectDomainList ?? [], .. preSocksResult.Context.ProtectDomainList ?? []],
|
[.. nodeContext.ProtectDomainList, .. preSocksResult.Context.ProtectDomainList],
|
||||||
ProtectCoreTypeList = protectCoreTypeList,
|
ProtectCoreTypeList = protectCoreTypeList,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -321,14 +324,14 @@ public class CoreConfigContextBuilder
|
|||||||
{
|
{
|
||||||
return await RegisterGroupNodeAsync(context, node);
|
return await RegisterGroupNodeAsync(context, node);
|
||||||
}
|
}
|
||||||
return RegisterSingleNodeAsync(context, node);
|
return await RegisterSingleNodeAsync(context, node);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Validates a single (non-group) node and, on success, adds it to the proxy map
|
/// Validates a single (non-group) node and, on success, adds it to the proxy map
|
||||||
/// and records any domain addresses that should bypass the proxy.
|
/// and records any domain addresses that should bypass the proxy.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static NodeValidatorResult RegisterSingleNodeAsync(CoreConfigContext context, ProfileItem node)
|
private static async Task<NodeValidatorResult> RegisterSingleNodeAsync(CoreConfigContext context, ProfileItem node)
|
||||||
{
|
{
|
||||||
if (node.ConfigType.IsGroupType())
|
if (node.ConfigType.IsGroupType())
|
||||||
{
|
{
|
||||||
@@ -336,6 +339,29 @@ public class CoreConfigContextBuilder
|
|||||||
}
|
}
|
||||||
|
|
||||||
var nodeValidatorResult = NodeValidator.Validate(node, context.RunCoreType);
|
var nodeValidatorResult = NodeValidator.Validate(node, context.RunCoreType);
|
||||||
|
|
||||||
|
if (node.ConfigType == EConfigType.Outbound)
|
||||||
|
{
|
||||||
|
var addressFileName = node.Address;
|
||||||
|
if (!File.Exists(addressFileName))
|
||||||
|
{
|
||||||
|
addressFileName = Utils.GetConfigPath(addressFileName);
|
||||||
|
}
|
||||||
|
if (!File.Exists(addressFileName))
|
||||||
|
{
|
||||||
|
nodeValidatorResult.Errors.Add(string.Format(ResUI.MsgCustomOutboundFileNotFound, node.Remarks, addressFileName));
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var fileContent = await File.ReadAllTextAsync(addressFileName);
|
||||||
|
context.CustomOutboundContent[node.IndexId] = fileContent;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
nodeValidatorResult.Errors.Add(string.Format(ResUI.MsgCustomOutboundFileNotFound, node.Remarks, addressFileName));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var msgs = new List<string>([.. nodeValidatorResult.Errors, .. nodeValidatorResult.Warnings]);
|
var msgs = new List<string>([.. nodeValidatorResult.Errors, .. nodeValidatorResult.Warnings]);
|
||||||
if (msgs.Count > 0)
|
if (msgs.Count > 0)
|
||||||
{
|
{
|
||||||
@@ -359,7 +385,7 @@ public class CoreConfigContextBuilder
|
|||||||
{
|
{
|
||||||
var echQuerySni = node.Sni;
|
var echQuerySni = node.Sni;
|
||||||
if (node.StreamSecurity == Global.StreamSecurity
|
if (node.StreamSecurity == Global.StreamSecurity
|
||||||
&& node.EchConfigList?.Contains("://") == true)
|
&& node.EchConfigList.Contains("://"))
|
||||||
{
|
{
|
||||||
var idx = node.EchConfigList.IndexOf('+');
|
var idx = node.EchConfigList.IndexOf('+');
|
||||||
echQuerySni = idx > 0 ? node.EchConfigList[..idx] : node.Sni;
|
echQuerySni = idx > 0 ? node.EchConfigList[..idx] : node.Sni;
|
||||||
@@ -437,7 +463,7 @@ public class CoreConfigContextBuilder
|
|||||||
|
|
||||||
if (!childNode.ConfigType.IsGroupType())
|
if (!childNode.ConfigType.IsGroupType())
|
||||||
{
|
{
|
||||||
var childNodeResult = RegisterSingleNodeAsync(context, childNode);
|
var childNodeResult = await RegisterSingleNodeAsync(context, childNode);
|
||||||
childNodeValidatorResult.Warnings.AddRange(childNodeResult.Warnings.Select(w =>
|
childNodeValidatorResult.Warnings.AddRange(childNodeResult.Warnings.Select(w =>
|
||||||
string.Format(ResUI.MsgGroupChildNodeWarning, node.Remarks, childNode.Remarks, w)));
|
string.Format(ResUI.MsgGroupChildNodeWarning, node.Remarks, childNode.Remarks, w)));
|
||||||
childNodeValidatorResult.Errors.AddRange(childNodeResult.Errors.Select(e =>
|
childNodeValidatorResult.Errors.AddRange(childNodeResult.Errors.Select(e =>
|
||||||
|
|||||||
@@ -36,6 +36,15 @@ public class NodeValidator
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (item.ConfigType is EConfigType.Outbound)
|
||||||
|
{
|
||||||
|
if (item.CoreType != coreType)
|
||||||
|
{
|
||||||
|
v.Error(string.Format(ResUI.MsgCoreNotSupportProtocol, coreType.ToString(), item.ConfigType));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (item.ConfigType.IsGroupType())
|
if (item.ConfigType.IsGroupType())
|
||||||
{
|
{
|
||||||
// Group logic is handled in ValidateGroupNode
|
// Group logic is handled in ValidateGroupNode
|
||||||
@@ -106,10 +115,10 @@ public class NodeValidator
|
|||||||
|
|
||||||
if (item.GetNetwork() is nameof(ETransport.ws)
|
if (item.GetNetwork() is nameof(ETransport.ws)
|
||||||
&& item.EchConfigList.IsNullOrEmpty()
|
&& item.EchConfigList.IsNullOrEmpty()
|
||||||
&& item.GetAlpn()?.FirstOrDefault() == "h3")
|
&& item.GetAlpn()?.FirstOrDefault() is "h3" or "h2")
|
||||||
{
|
{
|
||||||
v.Warning(
|
v.Warning(
|
||||||
"WebSocket but ALPN is set to h3, the core may ignore the ALPN setting or cause unexpected issues.");
|
$"WebSocket but ALPN is set to {item.Alpn}, the core may ignore the ALPN setting or cause unexpected issues.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// TLS & Security
|
// TLS & Security
|
||||||
@@ -122,14 +131,6 @@ public class NodeValidator
|
|||||||
isCertProvided = false;
|
isCertProvided = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for deprecated allowInsecure property when TLS is enabled
|
|
||||||
if (item.GetAllowInsecure()
|
|
||||||
&& item.Cert.IsNullOrEmpty()
|
|
||||||
&& item.CertSha.IsNullOrEmpty())
|
|
||||||
{
|
|
||||||
v.Warning(ResUI.MsgAllowInsecureDeprecated);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((coreType == ECoreType.Xray
|
if ((coreType == ECoreType.Xray
|
||||||
&& item.GetAllowInsecure()
|
&& item.GetAllowInsecure()
|
||||||
&& !isCertProvided
|
&& !isCertProvided
|
||||||
@@ -138,6 +139,10 @@ public class NodeValidator
|
|||||||
&& item.GetAllowInsecure()
|
&& item.GetAllowInsecure()
|
||||||
&& !isCertProvided))
|
&& !isCertProvided))
|
||||||
{
|
{
|
||||||
|
if (coreType == ECoreType.Xray)
|
||||||
|
{
|
||||||
|
v.Warning(ResUI.MsgAllowInsecureDeprecated);
|
||||||
|
}
|
||||||
v.Warning(ResUI.MsgInsecureConfiguration);
|
v.Warning(ResUI.MsgInsecureConfiguration);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ public static class ConfigHandler
|
|||||||
EnableTun = false,
|
EnableTun = false,
|
||||||
Mtu = 9000,
|
Mtu = 9000,
|
||||||
IcmpRouting = Global.TunIcmpRoutingPolicies.First(),
|
IcmpRouting = Global.TunIcmpRoutingPolicies.First(),
|
||||||
EnableLegacyProtect = false,
|
EnableLegacyProtect = true,
|
||||||
};
|
};
|
||||||
config.GuiItem ??= new();
|
config.GuiItem ??= new();
|
||||||
if (!Global.RootCertProviders.Contains(config.GuiItem.RootCertProvider))
|
if (!Global.RootCertProviders.Contains(config.GuiItem.RootCertProvider))
|
||||||
@@ -115,10 +115,13 @@ public static class ConfigHandler
|
|||||||
config.ConstItem ??= new ConstItem();
|
config.ConstItem ??= new ConstItem();
|
||||||
|
|
||||||
config.SimpleDNSItem ??= InitBuiltinSimpleDNS();
|
config.SimpleDNSItem ??= InitBuiltinSimpleDNS();
|
||||||
|
config.SimpleDNSItem.BlockAAAAQuery ??= false;
|
||||||
|
config.SimpleDNSItem.FakeIPRange ??= Global.FakeIPRanges.FirstOrDefault();
|
||||||
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;
|
||||||
config.SimpleDNSItem.ParallelQuery ??= false;
|
config.SimpleDNSItem.ParallelQuery ??= false;
|
||||||
|
config.SimpleDNSItem.EnableHappyEyeballs ??= false;
|
||||||
|
|
||||||
config.SpeedTestItem ??= new();
|
config.SpeedTestItem ??= new();
|
||||||
if (config.SpeedTestItem.SpeedTestTimeout < 10)
|
if (config.SpeedTestItem.SpeedTestTimeout < 10)
|
||||||
@@ -133,10 +136,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();
|
||||||
@@ -168,10 +168,24 @@ public static class ConfigHandler
|
|||||||
config.Fragment4RayItem ??= new()
|
config.Fragment4RayItem ??= new()
|
||||||
{
|
{
|
||||||
Packets = "tlshello",
|
Packets = "tlshello",
|
||||||
Length = "50-100",
|
|
||||||
Interval = "10-20",
|
|
||||||
MaxSplit = "0"
|
|
||||||
};
|
};
|
||||||
|
config.Fragment4RayItem.MaxSplit ??= "0";
|
||||||
|
|
||||||
|
config.HappyEyeballs4RayItem ??= new()
|
||||||
|
{
|
||||||
|
TryDelayMs = 250,
|
||||||
|
PrioritizeIPv6 = false,
|
||||||
|
Interleave = 1,
|
||||||
|
MaxConcurrentTry = 4,
|
||||||
|
};
|
||||||
|
if ((config.Fragment4RayItem.Lengths ?? []).Count == 0)
|
||||||
|
{
|
||||||
|
config.Fragment4RayItem.Lengths = [config.Fragment4RayItem.Length ?? "50-100"];
|
||||||
|
}
|
||||||
|
if ((config.Fragment4RayItem.Delays ?? []).Count == 0)
|
||||||
|
{
|
||||||
|
config.Fragment4RayItem.Delays = [config.Fragment4RayItem.Interval ?? "10-20"];
|
||||||
|
}
|
||||||
config.GlobalHotkeys ??= [];
|
config.GlobalHotkeys ??= [];
|
||||||
|
|
||||||
if (config.SystemProxyItem.SystemProxyExceptions.IsNullOrEmpty())
|
if (config.SystemProxyItem.SystemProxyExceptions.IsNullOrEmpty())
|
||||||
@@ -366,6 +380,13 @@ public static class ConfigHandler
|
|||||||
{
|
{
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else if (profileItem.ConfigType == EConfigType.Outbound)
|
||||||
|
{
|
||||||
|
profileItem.Address = Utils.GetConfigPath(profileItem.Address);
|
||||||
|
if (await AddCustomOutboundServer(config, profileItem, false) == 0)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
await AddServerCommon(config, profileItem, true);
|
await AddServerCommon(config, profileItem, true);
|
||||||
@@ -447,12 +468,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)
|
||||||
@@ -462,7 +483,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;
|
||||||
@@ -474,7 +495,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;
|
||||||
}
|
}
|
||||||
@@ -484,7 +505,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;
|
||||||
}
|
}
|
||||||
@@ -495,7 +516,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;
|
||||||
}
|
}
|
||||||
@@ -505,7 +526,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;
|
||||||
}
|
}
|
||||||
@@ -514,7 +535,7 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -563,6 +584,43 @@ public static class ConfigHandler
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static async Task<int> AddCustomOutboundServer(Config config, ProfileItem profileItem, bool blDelete, bool toFile = true)
|
||||||
|
{
|
||||||
|
var fileName = profileItem.Address;
|
||||||
|
if (!File.Exists(fileName))
|
||||||
|
{
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
var ext = Path.GetExtension(fileName);
|
||||||
|
var newFileName = $"{Utils.GetGuid()}{ext}";
|
||||||
|
//newFileName = Path.Combine(Utile.GetTempPath(), newFileName);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.Copy(fileName, Utils.GetConfigPath(newFileName));
|
||||||
|
if (blDelete)
|
||||||
|
{
|
||||||
|
File.Delete(fileName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logging.SaveLog(_tag, ex);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
profileItem.Address = newFileName;
|
||||||
|
profileItem.ConfigType = EConfigType.Outbound;
|
||||||
|
if (profileItem.Remarks.IsNullOrEmpty())
|
||||||
|
{
|
||||||
|
profileItem.Remarks = $"import custom outbound@{DateTime.Now.ToString("yyyy/MM/dd HH:mm:ss")}";
|
||||||
|
}
|
||||||
|
|
||||||
|
await AddServerCommon(config, profileItem, toFile);
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Edit an existing custom server configuration
|
/// Edit an existing custom server configuration
|
||||||
/// Updates the server's properties without changing the file
|
/// Updates the server's properties without changing the file
|
||||||
@@ -584,6 +642,8 @@ public static class ConfigHandler
|
|||||||
item.CoreType = profileItem.CoreType;
|
item.CoreType = profileItem.CoreType;
|
||||||
item.DisplayLog = profileItem.DisplayLog;
|
item.DisplayLog = profileItem.DisplayLog;
|
||||||
item.PreSocksPort = profileItem.PreSocksPort;
|
item.PreSocksPort = profileItem.PreSocksPort;
|
||||||
|
|
||||||
|
item.ProtoExtra = profileItem.ProtoExtra;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (await SQLiteHelper.Instance.UpdateAsync(item) > 0)
|
if (await SQLiteHelper.Instance.UpdateAsync(item) > 0)
|
||||||
@@ -859,6 +919,7 @@ public static class ConfigHandler
|
|||||||
WgInterfaceAddress = profileItem.GetProtocolExtra().WgInterfaceAddress?.TrimEx(),
|
WgInterfaceAddress = profileItem.GetProtocolExtra().WgInterfaceAddress?.TrimEx(),
|
||||||
WgReserved = wgReserved,
|
WgReserved = wgReserved,
|
||||||
WgMtu = profileItem.GetProtocolExtra().WgMtu is null or <= 0 ? Global.TunMtus.First() : profileItem.GetProtocolExtra().WgMtu,
|
WgMtu = profileItem.GetProtocolExtra().WgMtu is null or <= 0 ? Global.TunMtus.First() : profileItem.GetProtocolExtra().WgMtu,
|
||||||
|
WgDns = profileItem.GetProtocolExtra().WgDns?.TrimEx(),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (profileItem.Password.IsNullOrEmpty())
|
if (profileItem.Password.IsNullOrEmpty())
|
||||||
@@ -1463,8 +1524,8 @@ public static class ConfigHandler
|
|||||||
var matchedChildProfiles = childProfiles?.Where(p =>
|
var matchedChildProfiles = childProfiles?.Where(p =>
|
||||||
p != null &&
|
p != null &&
|
||||||
p.IsValid() &&
|
p.IsValid() &&
|
||||||
!p.ConfigType.IsComplexType() &&
|
(!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)
|
||||||
@@ -1603,7 +1664,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;
|
||||||
}
|
}
|
||||||
@@ -1659,68 +1720,191 @@ public static class ConfigHandler
|
|||||||
}
|
}
|
||||||
|
|
||||||
var subItem = await AppManager.Instance.GetSubItem(subid);
|
var subItem = await AppManager.Instance.GetSubItem(subid);
|
||||||
var subRemarks = subItem?.Remarks;
|
|
||||||
var preSocksPort = subItem?.PreSocksPort;
|
|
||||||
|
|
||||||
List<ProfileItem>? lstProfiles = null;
|
if (subItem?.CustomCoreType is null)
|
||||||
//Is sing-box array configuration
|
|
||||||
if (lstProfiles is null || lstProfiles.Count <= 0)
|
|
||||||
{
|
{
|
||||||
lstProfiles = SingboxFmt.ResolveFullArray(strData, subRemarks);
|
return await AddBatchServersDefaultCustom(config, strData, subid, isSub, subItem);
|
||||||
}
|
}
|
||||||
//Is v2ray array configuration
|
|
||||||
if (lstProfiles is null || lstProfiles.Count <= 0)
|
return await AddBatchServersSpecificCustom(config, strData, subid, isSub, subItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<int> AddBatchServersDefaultCustom(
|
||||||
|
Config config,
|
||||||
|
string strData,
|
||||||
|
string subid,
|
||||||
|
bool isSub,
|
||||||
|
SubItem? subItem)
|
||||||
|
{
|
||||||
|
var subRemarks = subItem?.Remarks;
|
||||||
|
// Prioritize using complete custom parsing, followed by custom outbound parsing.
|
||||||
|
var lstProfiles = V2rayFmt.ResolveToCustom(strData, subRemarks);
|
||||||
|
if (lstProfiles.Count == 0)
|
||||||
{
|
{
|
||||||
lstProfiles = V2rayFmt.ResolveFullArray(strData, subRemarks);
|
lstProfiles = SingboxFmt.ResolveToCustom(strData, subRemarks);
|
||||||
}
|
}
|
||||||
if (lstProfiles is { Count: > 0 })
|
if (lstProfiles.Count == 0)
|
||||||
{
|
{
|
||||||
var count = 0;
|
lstProfiles = V2rayFmt.ResolveToCustomOutbound(strData, subRemarks);
|
||||||
foreach (var it in lstProfiles)
|
}
|
||||||
{
|
if (lstProfiles.Count == 0)
|
||||||
it.Subid = subid;
|
{
|
||||||
it.IsSub = isSub;
|
lstProfiles = SingboxFmt.ResolveToCustomOutbound(strData, subRemarks);
|
||||||
it.PreSocksPort = preSocksPort;
|
}
|
||||||
if (await AddCustomServer(config, it, true) == 0)
|
if (lstProfiles.Count > 0)
|
||||||
{
|
{
|
||||||
count++;
|
var count = await AddBatchCustomServers(config, lstProfiles, subid, isSub);
|
||||||
}
|
|
||||||
}
|
|
||||||
if (count > 0)
|
if (count > 0)
|
||||||
{
|
{
|
||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ProfileItem? profileItem = null;
|
if (HtmlPageFmt.IsHtmlPage(strData))
|
||||||
//Is sing-box configuration
|
|
||||||
profileItem ??= SingboxFmt.ResolveFull(strData, subRemarks);
|
|
||||||
//Is v2ray configuration
|
|
||||||
profileItem ??= V2rayFmt.ResolveFull(strData, subRemarks);
|
|
||||||
//Is Html Page
|
|
||||||
if (profileItem is null && HtmlPageFmt.IsHtmlPage(strData))
|
|
||||||
{
|
{
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
//Is Clash configuration
|
|
||||||
profileItem ??= ClashFmt.ResolveFull(strData, subRemarks);
|
var profileItem = ClashFmt.ResolveFull(strData, subRemarks)
|
||||||
//Is hysteria configuration
|
?? Hysteria2Fmt.ResolveFull2(strData, subRemarks);
|
||||||
profileItem ??= Hysteria2Fmt.ResolveFull2(strData, subRemarks);
|
|
||||||
if (profileItem is null || profileItem.Address.IsNullOrEmpty())
|
if (profileItem == null)
|
||||||
{
|
{
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
profileItem.Subid = subid;
|
profileItem.Subid = subid;
|
||||||
profileItem.IsSub = isSub;
|
profileItem.IsSub = isSub;
|
||||||
profileItem.PreSocksPort = preSocksPort;
|
profileItem.PreSocksPort = subItem?.PreSocksPort;
|
||||||
if (await AddCustomServer(config, profileItem, true) == 0)
|
|
||||||
|
return await AddCustomServer(config, profileItem, true) == 0 ? 1 : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<int> AddBatchServersSpecificCustom(
|
||||||
|
Config config,
|
||||||
|
string strData,
|
||||||
|
string subid,
|
||||||
|
bool isSub,
|
||||||
|
SubItem subItem)
|
||||||
|
{
|
||||||
|
var subRemarks = subItem.Remarks;
|
||||||
|
var customCoreType = subItem.CustomCoreType!.Value;
|
||||||
|
|
||||||
|
var lstProfiles = customCoreType switch
|
||||||
{
|
{
|
||||||
return 1;
|
ECoreType.Xray => V2rayFmt.ResolveToCustom(strData, subRemarks),
|
||||||
|
ECoreType.sing_box => SingboxFmt.ResolveToCustom(strData, subRemarks),
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (lstProfiles?.Count > 0)
|
||||||
|
{
|
||||||
|
var count = await AddBatchCustomServers(config, lstProfiles, subid, isSub);
|
||||||
|
if (count > 0)
|
||||||
|
{
|
||||||
|
return count;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
|
||||||
|
return await SaveCustomRawFileServer(config, strData, subid, isSub, subItem, customCoreType);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<int> AddBatchCustomServers(
|
||||||
|
Config config,
|
||||||
|
List<ProfileItem> lstProfiles,
|
||||||
|
string subid,
|
||||||
|
bool isSub)
|
||||||
|
{
|
||||||
|
var count = 0;
|
||||||
|
foreach (var it in lstProfiles)
|
||||||
{
|
{
|
||||||
return -1;
|
it.Subid = subid;
|
||||||
|
it.IsSub = isSub;
|
||||||
|
|
||||||
|
if (it.ConfigType == EConfigType.Custom)
|
||||||
|
{
|
||||||
|
if (await AddCustomServer(config, it, true) == 0)
|
||||||
|
{
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (await AddCustomOutboundServer(config, it, true) == 0)
|
||||||
|
{
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<int> SaveCustomRawFileServer(
|
||||||
|
Config config,
|
||||||
|
string strData,
|
||||||
|
string subid,
|
||||||
|
bool isSub,
|
||||||
|
SubItem subItem,
|
||||||
|
ECoreType customCoreType)
|
||||||
|
{
|
||||||
|
var ext = DetectFileExtension(strData);
|
||||||
|
var fileName = Utils.GetTempPath($"{Utils.GetGuid(false)}{ext}");
|
||||||
|
await File.WriteAllTextAsync(fileName, strData);
|
||||||
|
|
||||||
|
var profileItem = new ProfileItem
|
||||||
|
{
|
||||||
|
CoreType = customCoreType,
|
||||||
|
ConfigType = EConfigType.Custom,
|
||||||
|
Address = fileName,
|
||||||
|
Remarks = subItem.Remarks ?? customCoreType.ToString(),
|
||||||
|
Subid = subid,
|
||||||
|
IsSub = isSub,
|
||||||
|
PreSocksPort = subItem.PreSocksPort,
|
||||||
|
};
|
||||||
|
|
||||||
|
return await AddCustomServer(config, profileItem, true) == 0 ? 1 : -1;
|
||||||
|
|
||||||
|
static string DetectFileExtension(string data)
|
||||||
|
{
|
||||||
|
var trimmed = data.AsSpan().TrimStart();
|
||||||
|
if (trimmed.IsEmpty)
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed[0] is '{' or '[')
|
||||||
|
{
|
||||||
|
return ".json";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed.StartsWith("---"))
|
||||||
|
{
|
||||||
|
return ".yaml";
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var line in trimmed.EnumerateLines())
|
||||||
|
{
|
||||||
|
var lineTrimmed = line.TrimStart();
|
||||||
|
if (lineTrimmed.IsEmpty || lineTrimmed.StartsWith("#"))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var colonIndex = lineTrimmed.IndexOf(':');
|
||||||
|
if (colonIndex > 0)
|
||||||
|
{
|
||||||
|
var keySpan = lineTrimmed[..colonIndex];
|
||||||
|
if (!keySpan.Contains(' ') && !keySpan.Contains('\t'))
|
||||||
|
{
|
||||||
|
if (colonIndex == lineTrimmed.Length - 1 || lineTrimmed[colonIndex + 1] is ' ' or '\t' or '\r' or '\n')
|
||||||
|
{
|
||||||
|
return ".yaml";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Empty;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1821,6 +2005,7 @@ public static class ConfigHandler
|
|||||||
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.PolicyGroup or EConfigType.ProxyChain => await AddServerCommon(config, profileItem, false),
|
EConfigType.PolicyGroup or EConfigType.ProxyChain => await AddServerCommon(config, profileItem, false),
|
||||||
|
EConfigType.Outbound => await AddCustomOutboundServer(config, profileItem, true, false),
|
||||||
_ => -1,
|
_ => -1,
|
||||||
};
|
};
|
||||||
if (addStatus == 0)
|
if (addStatus == 0)
|
||||||
@@ -2014,6 +2199,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;
|
||||||
@@ -2022,6 +2208,7 @@ public static class ConfigHandler
|
|||||||
item.NextProfile = subItem.NextProfile;
|
item.NextProfile = subItem.NextProfile;
|
||||||
item.PreSocksPort = subItem.PreSocksPort;
|
item.PreSocksPort = subItem.PreSocksPort;
|
||||||
item.Memo = subItem.Memo;
|
item.Memo = subItem.Memo;
|
||||||
|
item.CustomCoreType = subItem.CustomCoreType;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.Id.IsNullOrEmpty())
|
if (item.Id.IsNullOrEmpty())
|
||||||
@@ -2062,7 +2249,7 @@ public static class ConfigHandler
|
|||||||
{
|
{
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
var customProfile = await SQLiteHelper.Instance.TableAsync<ProfileItem>().Where(t => t.Subid == subid && t.ConfigType == EConfigType.Custom).ToListAsync();
|
var customProfile = await SQLiteHelper.Instance.TableAsync<ProfileItem>().Where(t => t.Subid == subid && (t.ConfigType == EConfigType.Custom || t.ConfigType == EConfigType.Outbound)).ToListAsync();
|
||||||
if (isSub)
|
if (isSub)
|
||||||
{
|
{
|
||||||
await SQLiteHelper.Instance.ExecuteAsync($"delete from ProfileItem where isSub = 1 and subid = '{subid}'");
|
await SQLiteHelper.Instance.ExecuteAsync($"delete from ProfileItem where isSub = 1 and subid = '{subid}'");
|
||||||
@@ -2422,7 +2609,7 @@ public static class ConfigHandler
|
|||||||
items = await AppManager.Instance.RoutingItems();
|
items = await AppManager.Instance.RoutingItems();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!blImportAdvancedRules && items.Count(u => u.Remarks.StartsWith(ver)) > 0)
|
if (!blImportAdvancedRules && items.Count() > 0) // items.Count(u => u.Remarks.StartsWith(ver)) > 0)
|
||||||
{
|
{
|
||||||
//migrate
|
//migrate
|
||||||
//TODO Temporary code to be removed later
|
//TODO Temporary code to be removed later
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -66,34 +66,41 @@ public static class ConnectionHandler
|
|||||||
/// <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(IWebProxy? webProxy, 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 linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token);
|
||||||
|
var linkedToken = linkedCts.Token;
|
||||||
using var client = new HttpClient(new SocketsHttpHandler()
|
using var client = new HttpClient(new SocketsHttpHandler()
|
||||||
{
|
{
|
||||||
Proxy = webProxy,
|
Proxy = webProxy,
|
||||||
UseProxy = webProxy != null,
|
UseProxy = webProxy != null,
|
||||||
ConnectTimeout = TimeSpan.FromSeconds(3)
|
ConnectTimeout = Global.LocalFetch,
|
||||||
});
|
});
|
||||||
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
@@ -101,7 +108,7 @@ public static class ConnectionHandler
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets IP and country information through specified proxy.
|
/// Gets IP and country information through specified proxy.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static async Task<IpInfoResult?> GetIPInfo(IWebProxy? webProxy)
|
public static async Task<IpInfoResult?> GetIPInfo(IWebProxy? webProxy, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -112,7 +119,7 @@ public static class ConnectionHandler
|
|||||||
}
|
}
|
||||||
|
|
||||||
var downloadHandle = new DownloadService();
|
var downloadHandle = new DownloadService();
|
||||||
var result = await downloadHandle.TryDownloadString(url, webProxy, "");
|
var result = await downloadHandle.TryDownloadString(url, webProxy, "", cancellationToken);
|
||||||
if (result == null)
|
if (result == null)
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
@@ -129,6 +136,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,7 @@ public static class CoreConfigHandler
|
|||||||
{
|
{
|
||||||
result = node.CoreType switch
|
result = node.CoreType switch
|
||||||
{
|
{
|
||||||
ECoreType.mihomo => await new CoreConfigClashService(config).GenerateClientCustomConfig(node, fileName),
|
ECoreType.mihomo => await new CoreConfigClashService(config, context.IsTunEnabled).GenerateClientCustomConfig(node, fileName),
|
||||||
_ => await GenerateClientCustomConfig(node, fileName)
|
_ => await GenerateClientCustomConfig(node, fileName)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ public class AnytlsFmt : BaseFmt
|
|||||||
var query = Utils.ParseQueryString(parsedUrl.Query);
|
var query = Utils.ParseQueryString(parsedUrl.Query);
|
||||||
ResolveUriQuery(query, ref item);
|
ResolveUriQuery(query, ref item);
|
||||||
|
|
||||||
|
if (GetQueryValue(query, "insecure") == "1")
|
||||||
|
{
|
||||||
|
item.AllowInsecure = Global.StringTrue;
|
||||||
|
}
|
||||||
|
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,6 +46,10 @@ public class AnytlsFmt : BaseFmt
|
|||||||
}
|
}
|
||||||
var pw = item.Password;
|
var pw = item.Password;
|
||||||
var dicQuery = new Dictionary<string, string>();
|
var dicQuery = new Dictionary<string, string>();
|
||||||
|
if (item.GetAllowInsecure())
|
||||||
|
{
|
||||||
|
dicQuery.Add("insecure", "1");
|
||||||
|
}
|
||||||
ToUriQuery(item, Global.None, ref dicQuery);
|
ToUriQuery(item, Global.None, ref dicQuery);
|
||||||
|
|
||||||
return ToUri(EConfigType.Anytls, item.Address, item.Port, pw, dicQuery, remark);
|
return ToUri(EConfigType.Anytls, item.Address, item.Port, pw, dicQuery, remark);
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
using System.Collections.Specialized;
|
|
||||||
|
|
||||||
namespace ServiceLib.Handler.Fmt;
|
namespace ServiceLib.Handler.Fmt;
|
||||||
|
|
||||||
public class BaseFmt
|
public class BaseFmt
|
||||||
{
|
{
|
||||||
private static readonly string[] _allowInsecureArray = new[] { "insecure", "allowInsecure", "allow_insecure" };
|
|
||||||
|
|
||||||
private static string UrlEncodeSafe(string? value) => Utils.UrlEncode(value ?? string.Empty);
|
private static string UrlEncodeSafe(string? value) => Utils.UrlEncode(value ?? string.Empty);
|
||||||
|
|
||||||
protected static string GetIpv6(string address)
|
protected static string GetIpv6(string address)
|
||||||
@@ -67,7 +63,6 @@ public class BaseFmt
|
|||||||
{
|
{
|
||||||
dicQuery.Add("alpn", Utils.UrlEncode(item.Alpn));
|
dicQuery.Add("alpn", Utils.UrlEncode(item.Alpn));
|
||||||
}
|
}
|
||||||
ToUriQueryAllowInsecure(item, ref dicQuery);
|
|
||||||
}
|
}
|
||||||
if (item.EchConfigList.IsNotEmpty())
|
if (item.EchConfigList.IsNotEmpty())
|
||||||
{
|
{
|
||||||
@@ -196,25 +191,6 @@ public class BaseFmt
|
|||||||
dicQuery.Add("alpn", Utils.UrlEncode(item.Alpn));
|
dicQuery.Add("alpn", Utils.UrlEncode(item.Alpn));
|
||||||
}
|
}
|
||||||
|
|
||||||
ToUriQueryAllowInsecure(item, ref dicQuery);
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int ToUriQueryAllowInsecure(ProfileItem item, ref Dictionary<string, string> dicQuery)
|
|
||||||
{
|
|
||||||
if (item.GetAllowInsecure())
|
|
||||||
{
|
|
||||||
// Add two for compatibility
|
|
||||||
dicQuery.Add("insecure", "1");
|
|
||||||
dicQuery.Add("allowInsecure", "1");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
dicQuery.Add("insecure", "0");
|
|
||||||
dicQuery.Add("allowInsecure", "0");
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,19 +228,6 @@ public class BaseFmt
|
|||||||
item.Finalmask = string.Empty;
|
item.Finalmask = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_allowInsecureArray.Any(k => GetQueryDecoded(query, k) == "1"))
|
|
||||||
{
|
|
||||||
item.AllowInsecure = Global.StringTrue;
|
|
||||||
}
|
|
||||||
else if (_allowInsecureArray.Any(k => GetQueryDecoded(query, k) == "0"))
|
|
||||||
{
|
|
||||||
item.AllowInsecure = Global.StringFalse;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
item.AllowInsecure = string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
var net = GetQueryValue(query, "type", nameof(ETransport.raw));
|
var net = GetQueryValue(query, "type", nameof(ETransport.raw));
|
||||||
if (net == Global.RawNetworkAlias)
|
if (net == Global.RawNetworkAlias)
|
||||||
{
|
{
|
||||||
@@ -379,8 +342,13 @@ public class BaseFmt
|
|||||||
return query[key] ?? defaultValue;
|
return query[key] ?? defaultValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Values are already unescaped by <see cref="Utils.ParseQueryString" />, so this must not
|
||||||
|
/// unescape them a second time: a value that still holds a valid percent sequence after the
|
||||||
|
/// first pass - an obfuscation password of "ob%41fs", say - would decay into "obAfs".
|
||||||
|
/// </summary>
|
||||||
protected static string GetQueryDecoded(NameValueCollection query, string key, string defaultValue = "")
|
protected static string GetQueryDecoded(NameValueCollection query, string key, string defaultValue = "")
|
||||||
{
|
{
|
||||||
return Utils.UrlDecode(GetQueryValue(query, key, defaultValue));
|
return GetQueryValue(query, key, defaultValue);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
using System.Collections.Specialized;
|
|
||||||
|
|
||||||
namespace ServiceLib.Handler.Fmt;
|
namespace ServiceLib.Handler.Fmt;
|
||||||
|
|
||||||
public class Hysteria2Fmt : BaseFmt
|
public class Hysteria2Fmt : BaseFmt
|
||||||
@@ -19,7 +17,11 @@ public class Hysteria2Fmt : BaseFmt
|
|||||||
}
|
}
|
||||||
|
|
||||||
item.Address = url.IdnHost;
|
item.Address = url.IdnHost;
|
||||||
item.Port = url.Port;
|
// The URI scheme makes the port optional and defaults it to 443. Uri.Port answers -1 for
|
||||||
|
// an unregistered scheme carrying no port, which ProfileItem.IsValid then rejects.
|
||||||
|
// Only -1 means "omitted": an explicit ":0" has to stay 0 and be rejected the way it
|
||||||
|
// always was, instead of being quietly redirected to a server the link never named.
|
||||||
|
item.Port = url.Port == -1 ? 443 : url.Port;
|
||||||
item.Remarks = url.GetComponents(UriComponents.Fragment, UriFormat.Unescaped);
|
item.Remarks = url.GetComponents(UriComponents.Fragment, UriFormat.Unescaped);
|
||||||
item.Password = Utils.UrlDecode(url.UserInfo);
|
item.Password = Utils.UrlDecode(url.UserInfo);
|
||||||
|
|
||||||
@@ -162,10 +164,27 @@ public class Hysteria2Fmt : BaseFmt
|
|||||||
|
|
||||||
private static void ResolveHy2UriQuery(NameValueCollection query, ref ProfileItem item)
|
private static void ResolveHy2UriQuery(NameValueCollection query, ref ProfileItem item)
|
||||||
{
|
{
|
||||||
|
if (GetQueryValue(query, "insecure") == "1")
|
||||||
|
{
|
||||||
|
item.AllowInsecure = Global.StringTrue;
|
||||||
|
}
|
||||||
if (item.CertSha.IsNullOrEmpty())
|
if (item.CertSha.IsNullOrEmpty())
|
||||||
{
|
{
|
||||||
item.CertSha = GetQueryDecoded(query, "pinSHA256");
|
var pinSHA256 = GetQueryDecoded(query, "pinSHA256");
|
||||||
|
item.CertSha = pinSHA256;
|
||||||
|
if (!pinSHA256.IsNullOrEmpty())
|
||||||
|
{
|
||||||
|
// NOTE:
|
||||||
|
// To accommodate Xray changes,
|
||||||
|
// some providers issue self-signed cert links with `insecure = false` and a certificate fingerprint,
|
||||||
|
// breaking interoperability between Xray, official Hysteria 2 client, and sing-box.
|
||||||
|
// Since this won't compromise the overall security model,
|
||||||
|
// `insecure = true` is automatically set when a fingerprint is detected,
|
||||||
|
// and the value is restored when generating configurations.
|
||||||
|
item.AllowInsecure = Global.StringTrue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
item.EchConfigList = GetQueryDecoded(query, "ech");
|
||||||
item.SetProtocolExtra(item.GetProtocolExtra() with
|
item.SetProtocolExtra(item.GetProtocolExtra() with
|
||||||
{
|
{
|
||||||
Ports = GetQueryDecoded(query, "mport"),
|
Ports = GetQueryDecoded(query, "mport"),
|
||||||
@@ -198,12 +217,25 @@ public class Hysteria2Fmt : BaseFmt
|
|||||||
|
|
||||||
private static void ToHy2UriQuery(ProfileItem item, ref Dictionary<string, string> dicQuery)
|
private static void ToHy2UriQuery(ProfileItem item, ref Dictionary<string, string> dicQuery)
|
||||||
{
|
{
|
||||||
|
if (item.GetAllowInsecure())
|
||||||
|
{
|
||||||
|
dicQuery.Add("insecure", "1");
|
||||||
|
}
|
||||||
if (!item.CertSha.IsNullOrEmpty()
|
if (!item.CertSha.IsNullOrEmpty()
|
||||||
&& !item.CertSha.Contains(','))
|
&& !item.CertSha.Contains(','))
|
||||||
{
|
{
|
||||||
var sha = item.CertSha;
|
var sha = item.CertSha;
|
||||||
dicQuery.Add("pinSHA256", Utils.UrlEncode(sha));
|
dicQuery.Add("pinSHA256", Utils.UrlEncode(sha));
|
||||||
}
|
}
|
||||||
|
else if (!item.Cert.IsNullOrEmpty()
|
||||||
|
&& CertPemManager.GetLeafCertSha256Thumbprint(item.Cert) is { Length: > 0 } thumbprint)
|
||||||
|
{
|
||||||
|
dicQuery.Add("pinSHA256", Utils.UrlEncode(thumbprint));
|
||||||
|
}
|
||||||
|
if (!item.EchConfigList.IsNullOrEmpty())
|
||||||
|
{
|
||||||
|
dicQuery.Add("ech", Utils.UrlEncode(item.EchConfigList));
|
||||||
|
}
|
||||||
var protocolExtraItem = item.GetProtocolExtra();
|
var protocolExtraItem = item.GetProtocolExtra();
|
||||||
var isGecko = !protocolExtraItem.GeckoMinPacketSize.IsNullOrEmpty() || !protocolExtraItem.GeckoMaxPacketSize.IsNullOrEmpty();
|
var isGecko = !protocolExtraItem.GeckoMinPacketSize.IsNullOrEmpty() || !protocolExtraItem.GeckoMaxPacketSize.IsNullOrEmpty();
|
||||||
if (!protocolExtraItem.SalamanderPass.IsNullOrEmpty())
|
if (!protocolExtraItem.SalamanderPass.IsNullOrEmpty())
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
namespace ServiceLib.Handler.Fmt;
|
namespace ServiceLib.Handler.Fmt;
|
||||||
|
|
||||||
public class InnerFmt
|
public class InnerFmt : BaseFmt
|
||||||
{
|
{
|
||||||
private static readonly Lazy<string> SessionSalt = new(() => Utils.GetGuid(false));
|
private static readonly Lazy<string> SessionSalt = new(() => Utils.GetGuid(false));
|
||||||
|
|
||||||
@@ -50,19 +50,19 @@ public class InnerFmt
|
|||||||
var protocolExtra = item.GetProtocolExtra();
|
var protocolExtra = item.GetProtocolExtra();
|
||||||
// Only allow "self" as a special value for SubChildItems to avoid possible sources of attacks,
|
// Only allow "self" as a special value for SubChildItems to avoid possible sources of attacks,
|
||||||
// which means it will be replaced with the subid, otherwise set it to null
|
// which means it will be replaced with the subid, otherwise set it to null
|
||||||
//if (!protocolExtra.SubChildItems.IsNullOrEmpty())
|
// if (!protocolExtra.SubChildItems.IsNullOrEmpty())
|
||||||
if (protocolExtra.SubChildItems == "self")
|
if (protocolExtra.SubChildItems == "self")
|
||||||
{
|
{
|
||||||
protocolExtra = protocolExtra with
|
protocolExtra = protocolExtra with
|
||||||
{
|
{
|
||||||
SubChildItems = subid
|
SubChildItems = subid,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
protocolExtra = protocolExtra with
|
protocolExtra = protocolExtra with
|
||||||
{
|
{
|
||||||
SubChildItems = null
|
SubChildItems = null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (Utils.String2List(protocolExtra.ChildItems) is { Count: > 0 } childIndexIds)
|
if (Utils.String2List(protocolExtra.ChildItems) is { Count: > 0 } childIndexIds)
|
||||||
@@ -73,14 +73,14 @@ public class InnerFmt
|
|||||||
.ToList();
|
.ToList();
|
||||||
protocolExtra = protocolExtra with
|
protocolExtra = protocolExtra with
|
||||||
{
|
{
|
||||||
ChildItems = Utils.List2String(newChildIndexIds)
|
ChildItems = Utils.List2String(newChildIndexIds),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
protocolExtra = protocolExtra with
|
protocolExtra = protocolExtra with
|
||||||
{
|
{
|
||||||
ChildItems = null
|
ChildItems = null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
item.SetProtocolExtra(protocolExtra);
|
item.SetProtocolExtra(protocolExtra);
|
||||||
@@ -120,7 +120,7 @@ public class InnerFmt
|
|||||||
{
|
{
|
||||||
protocolExtra = protocolExtra with
|
protocolExtra = protocolExtra with
|
||||||
{
|
{
|
||||||
SubChildItems = "self"
|
SubChildItems = "self",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (Utils.String2List(protocolExtra.ChildItems) is { Count: > 0 } childIndexIds)
|
if (Utils.String2List(protocolExtra.ChildItems) is { Count: > 0 } childIndexIds)
|
||||||
@@ -131,7 +131,7 @@ public class InnerFmt
|
|||||||
.ToList();
|
.ToList();
|
||||||
protocolExtra = protocolExtra with
|
protocolExtra = protocolExtra with
|
||||||
{
|
{
|
||||||
ChildItems = Utils.List2String(newChildIndexIds)
|
ChildItems = Utils.List2String(newChildIndexIds),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
itemClone.SetProtocolExtra(protocolExtra);
|
itemClone.SetProtocolExtra(protocolExtra);
|
||||||
@@ -175,6 +175,19 @@ public class InnerFmt
|
|||||||
jsonObj["TransportExtra"] = JsonUtils.Serialize(transportExtraObj, false);
|
jsonObj["TransportExtra"] = JsonUtils.Serialize(transportExtraObj, false);
|
||||||
jsonObj.Remove("TransportExtraObj");
|
jsonObj.Remove("TransportExtraObj");
|
||||||
}
|
}
|
||||||
|
var customOutboundFilePath = string.Empty;
|
||||||
|
if (jsonObj.TryGetPropertyValue("CustomOutboundObj", out var customOutboundNode)
|
||||||
|
&& customOutboundNode is JsonObject customOutboundObj)
|
||||||
|
{
|
||||||
|
var customOutboundContent = JsonUtils.Serialize(customOutboundObj, new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
WriteIndented = true,
|
||||||
|
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
|
||||||
|
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||||
|
});
|
||||||
|
customOutboundFilePath = WriteAllText(customOutboundContent);
|
||||||
|
jsonObj.Remove("CustomOutboundObj");
|
||||||
|
}
|
||||||
var profileItem = JsonUtils.Deserialize<ProfileItem>(JsonUtils.Serialize(jsonObj, false));
|
var profileItem = JsonUtils.Deserialize<ProfileItem>(JsonUtils.Serialize(jsonObj, false));
|
||||||
if (profileItem is null)
|
if (profileItem is null)
|
||||||
{
|
{
|
||||||
@@ -193,6 +206,14 @@ public class InnerFmt
|
|||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
if (profileItem.ConfigType is EConfigType.Outbound)
|
||||||
|
{
|
||||||
|
if (customOutboundFilePath.IsNullOrEmpty())
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
profileItem.Address = customOutboundFilePath;
|
||||||
|
}
|
||||||
var protocolExtra = profileItem.GetProtocolExtra();
|
var protocolExtra = profileItem.GetProtocolExtra();
|
||||||
var multipleLoad = protocolExtra.MultipleLoad;
|
var multipleLoad = protocolExtra.MultipleLoad;
|
||||||
if (multipleLoad is not null && !Enum.IsDefined(typeof(EMultipleLoad), multipleLoad))
|
if (multipleLoad is not null && !Enum.IsDefined(typeof(EMultipleLoad), multipleLoad))
|
||||||
@@ -209,6 +230,26 @@ public class InnerFmt
|
|||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
if (item.ConfigType is EConfigType.Outbound)
|
||||||
|
{
|
||||||
|
var customOutboundFilePath = item.Address;
|
||||||
|
if (!File.Exists(customOutboundFilePath))
|
||||||
|
{
|
||||||
|
customOutboundFilePath = Utils.GetConfigPath(customOutboundFilePath);
|
||||||
|
}
|
||||||
|
if (!File.Exists(customOutboundFilePath))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!customOutboundFilePath.IsNullOrEmpty()
|
||||||
|
&& File.Exists(customOutboundFilePath)
|
||||||
|
&& File.ReadAllText(customOutboundFilePath) is { Length: > 0 } customOutboundContent
|
||||||
|
&& JsonUtils.ParseJson(customOutboundContent) is JsonObject customOutboundObj)
|
||||||
|
{
|
||||||
|
jsonObj["CustomOutboundObj"] = customOutboundObj;
|
||||||
|
jsonObj.Remove("Address");
|
||||||
|
}
|
||||||
|
}
|
||||||
// unflatten
|
// unflatten
|
||||||
// move jsonObj.ProtoExtra (string) to jsonObj.ProtoExtraObj
|
// move jsonObj.ProtoExtra (string) to jsonObj.ProtoExtraObj
|
||||||
// move jsonObj.TransportExtra (string) to jsonObj.TransportExtraObj
|
// move jsonObj.TransportExtra (string) to jsonObj.TransportExtraObj
|
||||||
@@ -296,7 +337,7 @@ public class InnerFmt
|
|||||||
JsonValue value when value.TryGetValue<string>(out var str) => string.IsNullOrEmpty(str),
|
JsonValue value when value.TryGetValue<string>(out var str) => string.IsNullOrEmpty(str),
|
||||||
JsonObject obj => obj.Count == 0,
|
JsonObject obj => obj.Count == 0,
|
||||||
JsonArray arr => arr.Count == 0,
|
JsonArray arr => arr.Count == 0,
|
||||||
_ => false
|
_ => false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,19 +2,103 @@ namespace ServiceLib.Handler.Fmt;
|
|||||||
|
|
||||||
public class SingboxFmt : BaseFmt
|
public class SingboxFmt : BaseFmt
|
||||||
{
|
{
|
||||||
public static List<ProfileItem>? ResolveFullArray(string strData, string? subRemarks)
|
public static List<ProfileItem> ResolveToCustom(string strData, string? subRemarks)
|
||||||
{
|
{
|
||||||
var configObjects = JsonUtils.Deserialize<object[]>(strData);
|
var jsonNode = JsonUtils.ParseJson(strData);
|
||||||
if (configObjects is not { Length: > 0 })
|
return ResolveCommon(jsonNode, subRemarks, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<ProfileItem> ResolveToCustomOutbound(string strData, string? subRemarks)
|
||||||
|
{
|
||||||
|
var jsonNode = JsonUtils.ParseJson(strData);
|
||||||
|
return ResolveCommon(jsonNode, subRemarks, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<ProfileItem> ResolveCommon(JsonNode? jsonNode, string? subRemarks, bool isOutbound)
|
||||||
|
{
|
||||||
|
if (jsonNode is JsonArray jsonArray)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
[
|
||||||
|
.. jsonArray.Select(item => ResolveCommon(item, subRemarks, isOutbound))
|
||||||
|
.Where(list => list is { Count: > 0 })
|
||||||
|
.SelectMany(list => list),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (jsonNode is not JsonObject jsonObject)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
// Process the individual JSON object
|
||||||
|
var profileList = new List<ProfileItem>();
|
||||||
|
if (!isOutbound)
|
||||||
|
{
|
||||||
|
var fullProfile = ResolveFull(jsonObject, subRemarks);
|
||||||
|
if (fullProfile is not null)
|
||||||
|
{
|
||||||
|
profileList.Add(fullProfile);
|
||||||
|
return profileList;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
profileList.AddRange(ResolveFullToOutbound(jsonObject, subRemarks));
|
||||||
|
if (profileList.Count != 0)
|
||||||
|
{
|
||||||
|
return profileList;
|
||||||
|
}
|
||||||
|
var outboundProfile = ResolveOutbound(jsonObject, subRemarks);
|
||||||
|
if (outboundProfile is not null)
|
||||||
|
{
|
||||||
|
profileList.Add(outboundProfile);
|
||||||
|
}
|
||||||
|
return profileList;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ProfileItem? ResolveFull(JsonObject jsonObject, string? subRemarks)
|
||||||
|
{
|
||||||
|
if (jsonObject?["inbounds"] == null
|
||||||
|
|| jsonObject["outbounds"] == null)
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
List<ProfileItem> lstResult = [];
|
if (jsonObject["outbounds"] is JsonArray outboundsArray)
|
||||||
foreach (var configObject in configObjects)
|
|
||||||
{
|
{
|
||||||
var objectString = JsonUtils.Serialize(configObject);
|
if (!outboundsArray.Any(IsValidSingboxOutbound))
|
||||||
var profileIt = ResolveFull(objectString, subRemarks);
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var fileName = WriteAllText(JsonUtils.Serialize(jsonObject));
|
||||||
|
var profileItem = new ProfileItem
|
||||||
|
{
|
||||||
|
ConfigType = EConfigType.Custom,
|
||||||
|
CoreType = ECoreType.sing_box,
|
||||||
|
Address = fileName,
|
||||||
|
Remarks = subRemarks ?? "singbox_custom",
|
||||||
|
};
|
||||||
|
|
||||||
|
return profileItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<ProfileItem> ResolveFullToOutbound(JsonObject jsonObject, string? subRemarks)
|
||||||
|
{
|
||||||
|
if (jsonObject?["outbounds"] is not JsonArray outboundsArray)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
List<ProfileItem> lstResult = [];
|
||||||
|
foreach (var outbound in outboundsArray)
|
||||||
|
{
|
||||||
|
if (outbound is not JsonObject outboundObj)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
var profileIt = ResolveOutbound(outboundObj, subRemarks);
|
||||||
if (profileIt != null)
|
if (profileIt != null)
|
||||||
{
|
{
|
||||||
lstResult.Add(profileIt);
|
lstResult.Add(profileIt);
|
||||||
@@ -23,25 +107,58 @@ public class SingboxFmt : BaseFmt
|
|||||||
return lstResult;
|
return lstResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static ProfileItem? ResolveFull(string strData, string? subRemarks)
|
private static ProfileItem? ResolveOutbound(JsonObject jsonObject, string? subRemarks)
|
||||||
{
|
{
|
||||||
var config = JsonUtils.ParseJson(strData);
|
if (!IsValidSingboxOutbound(jsonObject))
|
||||||
if (config?["inbounds"] == null
|
|
||||||
|| config["outbounds"] == null
|
|
||||||
|| config["route"] == null
|
|
||||||
|| config["dns"] == null)
|
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
var type = jsonObject["type"]?.ToString();
|
||||||
var fileName = WriteAllText(strData);
|
if (type is null or "direct" or "block" or "dns" or "selector" or "urltest")
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
var tag = jsonObject["tag"]?.ToString();
|
||||||
|
var remarks = $"{type}_{tag}";
|
||||||
|
var fileName = WriteAllText(JsonUtils.Serialize(jsonObject));
|
||||||
var profileItem = new ProfileItem
|
var profileItem = new ProfileItem
|
||||||
{
|
{
|
||||||
|
ConfigType = EConfigType.Outbound,
|
||||||
CoreType = ECoreType.sing_box,
|
CoreType = ECoreType.sing_box,
|
||||||
Address = fileName,
|
Address = fileName,
|
||||||
Remarks = subRemarks ?? "singbox_custom"
|
Remarks = remarks,
|
||||||
};
|
};
|
||||||
|
|
||||||
return profileItem;
|
return profileItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool IsValidSingboxOutbound(JsonNode? jsonNode)
|
||||||
|
{
|
||||||
|
if (jsonNode is not JsonObject jsonObject)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
var matchedCounter = 0;
|
||||||
|
if (string.IsNullOrEmpty(jsonObject["type"]?.ToString()))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
matchedCounter += 1;
|
||||||
|
if (!string.IsNullOrEmpty(jsonObject["tag"]?.ToString()))
|
||||||
|
{
|
||||||
|
matchedCounter += 1;
|
||||||
|
}
|
||||||
|
if (!string.IsNullOrEmpty(jsonObject["server"]?.ToString()))
|
||||||
|
{
|
||||||
|
matchedCounter += 1;
|
||||||
|
}
|
||||||
|
if (!string.IsNullOrEmpty(jsonObject["server_port"]?.ToString()))
|
||||||
|
{
|
||||||
|
matchedCounter += 1;
|
||||||
|
}
|
||||||
|
if (!string.IsNullOrEmpty(jsonObject["tls"]?.ToString()))
|
||||||
|
{
|
||||||
|
matchedCounter += 1;
|
||||||
|
}
|
||||||
|
return matchedCounter >= 2;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ namespace ServiceLib.Handler.Fmt;
|
|||||||
|
|
||||||
public class TrojanFmt : BaseFmt
|
public class TrojanFmt : BaseFmt
|
||||||
{
|
{
|
||||||
|
private static readonly List<string> _insecureQueryKeys = new() { "allowInsecure", "insecure" };
|
||||||
|
|
||||||
public static ProfileItem? Resolve(string str, out string msg)
|
public static ProfileItem? Resolve(string str, out string msg)
|
||||||
{
|
{
|
||||||
msg = ResUI.ConfigurationFormatIncorrect;
|
msg = ResUI.ConfigurationFormatIncorrect;
|
||||||
@@ -23,6 +25,10 @@ public class TrojanFmt : BaseFmt
|
|||||||
item.Password = Utils.UrlDecode(url.UserInfo);
|
item.Password = Utils.UrlDecode(url.UserInfo);
|
||||||
|
|
||||||
var query = Utils.ParseQueryString(url.Query);
|
var query = Utils.ParseQueryString(url.Query);
|
||||||
|
if (_insecureQueryKeys.Any(q => GetQueryValue(query, q) == "1"))
|
||||||
|
{
|
||||||
|
item.AllowInsecure = Global.StringTrue;
|
||||||
|
}
|
||||||
item.SetProtocolExtra(item.GetProtocolExtra() with { Flow = GetQueryValue(query, "flow") });
|
item.SetProtocolExtra(item.GetProtocolExtra() with { Flow = GetQueryValue(query, "flow") });
|
||||||
ResolveUriQuery(query, ref item);
|
ResolveUriQuery(query, ref item);
|
||||||
|
|
||||||
@@ -41,6 +47,10 @@ public class TrojanFmt : BaseFmt
|
|||||||
remark = "#" + Utils.UrlEncode(item.Remarks);
|
remark = "#" + Utils.UrlEncode(item.Remarks);
|
||||||
}
|
}
|
||||||
var dicQuery = new Dictionary<string, string>();
|
var dicQuery = new Dictionary<string, string>();
|
||||||
|
if (item.GetAllowInsecure())
|
||||||
|
{
|
||||||
|
_insecureQueryKeys.ForEach(q => dicQuery.Add(q, "1"));
|
||||||
|
}
|
||||||
if (!item.GetProtocolExtra().Flow.IsNullOrEmpty())
|
if (!item.GetProtocolExtra().Flow.IsNullOrEmpty())
|
||||||
{
|
{
|
||||||
dicQuery.Add("flow", item.GetProtocolExtra().Flow);
|
dicQuery.Add("flow", item.GetProtocolExtra().Flow);
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ public class TuicFmt : BaseFmt
|
|||||||
|
|
||||||
var query = Utils.ParseQueryString(url.Query);
|
var query = Utils.ParseQueryString(url.Query);
|
||||||
ResolveUriQuery(query, ref item);
|
ResolveUriQuery(query, ref item);
|
||||||
|
if (GetQueryValue(query, "allow_insecure") == "1")
|
||||||
|
{
|
||||||
|
item.AllowInsecure = Global.StringTrue;
|
||||||
|
}
|
||||||
item.SetProtocolExtra(item.GetProtocolExtra() with
|
item.SetProtocolExtra(item.GetProtocolExtra() with
|
||||||
{
|
{
|
||||||
CongestionControl = GetQueryValue(query, "congestion_control")
|
CongestionControl = GetQueryValue(query, "congestion_control")
|
||||||
@@ -53,7 +57,10 @@ public class TuicFmt : BaseFmt
|
|||||||
|
|
||||||
var dicQuery = new Dictionary<string, string>();
|
var dicQuery = new Dictionary<string, string>();
|
||||||
ToUriQueryLite(item, ref dicQuery);
|
ToUriQueryLite(item, ref dicQuery);
|
||||||
|
if (item.GetAllowInsecure())
|
||||||
|
{
|
||||||
|
dicQuery.Add("allow_insecure", "1");
|
||||||
|
}
|
||||||
if (!item.GetProtocolExtra().CongestionControl.IsNullOrEmpty())
|
if (!item.GetProtocolExtra().CongestionControl.IsNullOrEmpty())
|
||||||
{
|
{
|
||||||
dicQuery.Add("congestion_control", item.GetProtocolExtra().CongestionControl);
|
dicQuery.Add("congestion_control", item.GetProtocolExtra().CongestionControl);
|
||||||
|
|||||||
@@ -2,47 +2,166 @@ namespace ServiceLib.Handler.Fmt;
|
|||||||
|
|
||||||
public class V2rayFmt : BaseFmt
|
public class V2rayFmt : BaseFmt
|
||||||
{
|
{
|
||||||
public static List<ProfileItem>? ResolveFullArray(string strData, string? subRemarks)
|
public static List<ProfileItem> ResolveToCustom(string strData, string? subRemarks)
|
||||||
{
|
{
|
||||||
var configObjects = JsonUtils.Deserialize<object[]>(strData);
|
var jsonNode = JsonUtils.ParseJson(strData);
|
||||||
if (configObjects is not { Length: > 0 })
|
return ResolveCommon(jsonNode, subRemarks, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<ProfileItem> ResolveToCustomOutbound(string strData, string? subRemarks)
|
||||||
|
{
|
||||||
|
var jsonNode = JsonUtils.ParseJson(strData);
|
||||||
|
return ResolveCommon(jsonNode, subRemarks, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<ProfileItem> ResolveCommon(JsonNode? jsonNode, string? subRemarks, bool isOutbound)
|
||||||
|
{
|
||||||
|
if (jsonNode is JsonArray jsonArray)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
[
|
||||||
|
.. jsonArray.Select(item => ResolveCommon(item, subRemarks, isOutbound))
|
||||||
|
.Where(list => list is { Count: > 0 })
|
||||||
|
.SelectMany(list => list),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (jsonNode is not JsonObject jsonObject)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
// Process the individual JSON object
|
||||||
|
var profileList = new List<ProfileItem>();
|
||||||
|
if (!isOutbound)
|
||||||
|
{
|
||||||
|
var fullProfile = ResolveFull(jsonObject, subRemarks);
|
||||||
|
if (fullProfile is not null)
|
||||||
|
{
|
||||||
|
profileList.Add(fullProfile);
|
||||||
|
return profileList;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
profileList.AddRange(ResolveFullToOutbound(jsonObject, subRemarks));
|
||||||
|
if (profileList.Count != 0)
|
||||||
|
{
|
||||||
|
return profileList;
|
||||||
|
}
|
||||||
|
var outboundProfile = ResolveOutbound(jsonObject, subRemarks);
|
||||||
|
if (outboundProfile is not null)
|
||||||
|
{
|
||||||
|
profileList.Add(outboundProfile);
|
||||||
|
}
|
||||||
|
return profileList;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ProfileItem? ResolveFull(JsonObject jsonObject, string? subRemarks)
|
||||||
|
{
|
||||||
|
if (jsonObject?["inbounds"] == null
|
||||||
|
|| jsonObject["outbounds"] == null)
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
List<ProfileItem> lstResult = [];
|
if (jsonObject["outbounds"] is JsonArray outboundsArray)
|
||||||
foreach (var configObject in configObjects)
|
|
||||||
{
|
{
|
||||||
var objectString = JsonUtils.Serialize(configObject);
|
if (!outboundsArray.Any(IsValidV2rayOutbound))
|
||||||
var profileIt = ResolveFull(objectString, subRemarks);
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var fileName = WriteAllText(JsonUtils.Serialize(jsonObject));
|
||||||
|
|
||||||
|
var profileItem = new ProfileItem
|
||||||
|
{
|
||||||
|
ConfigType = EConfigType.Custom,
|
||||||
|
CoreType = ECoreType.Xray,
|
||||||
|
Address = fileName,
|
||||||
|
Remarks = jsonObject["remarks"]?.ToString() ?? subRemarks ?? "v2ray_custom",
|
||||||
|
};
|
||||||
|
|
||||||
|
return profileItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<ProfileItem> ResolveFullToOutbound(JsonObject jsonObject, string? subRemarks)
|
||||||
|
{
|
||||||
|
if (jsonObject["outbounds"] is not JsonArray outboundsArray)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
List<ProfileItem> lstResult = [];
|
||||||
|
foreach (var outbound in outboundsArray)
|
||||||
|
{
|
||||||
|
if (outbound is not JsonObject outboundObj)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
var profileIt = ResolveOutbound(outboundObj, subRemarks);
|
||||||
if (profileIt != null)
|
if (profileIt != null)
|
||||||
{
|
{
|
||||||
lstResult.Add(profileIt);
|
lstResult.Add(profileIt);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return lstResult;
|
return lstResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static ProfileItem? ResolveFull(string strData, string? subRemarks)
|
public static ProfileItem? ResolveOutbound(JsonObject jsonObject, string? subRemarks)
|
||||||
{
|
{
|
||||||
var config = JsonUtils.ParseJson(strData);
|
if (!IsValidV2rayOutbound(jsonObject))
|
||||||
if (config?["inbounds"] == null
|
|
||||||
|| config["outbounds"] == null
|
|
||||||
|| config["routing"] == null)
|
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
var protocol = jsonObject["protocol"]?.ToString();
|
||||||
var fileName = WriteAllText(strData);
|
if (protocol is null or "freedom" or "blackhole" or "dns" or "loopback")
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
var tag = jsonObject["tag"]?.ToString();
|
||||||
|
var remarks = $"{protocol}_{tag}";
|
||||||
|
var fileName = WriteAllText(JsonUtils.Serialize(jsonObject));
|
||||||
var profileItem = new ProfileItem
|
var profileItem = new ProfileItem
|
||||||
{
|
{
|
||||||
|
ConfigType = EConfigType.Outbound,
|
||||||
CoreType = ECoreType.Xray,
|
CoreType = ECoreType.Xray,
|
||||||
Address = fileName,
|
Address = fileName,
|
||||||
Remarks = config?["remarks"]?.ToString() ?? subRemarks ?? "v2ray_custom"
|
Remarks = remarks,
|
||||||
};
|
};
|
||||||
|
|
||||||
return profileItem;
|
return profileItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool IsValidV2rayOutbound(JsonNode? jsonNode)
|
||||||
|
{
|
||||||
|
if (jsonNode is not JsonObject jsonObject)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var matchedCounter = 0;
|
||||||
|
if (string.IsNullOrEmpty(jsonObject["protocol"]?.ToString()))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
matchedCounter += 1;
|
||||||
|
if (!string.IsNullOrEmpty(jsonObject["settings"]?.ToString()))
|
||||||
|
{
|
||||||
|
matchedCounter += 1;
|
||||||
|
}
|
||||||
|
if (!string.IsNullOrEmpty(jsonObject["streamSettings"]?.ToString()))
|
||||||
|
{
|
||||||
|
matchedCounter += 1;
|
||||||
|
}
|
||||||
|
if (!string.IsNullOrEmpty(jsonObject["tag"]?.ToString()))
|
||||||
|
{
|
||||||
|
matchedCounter += 1;
|
||||||
|
}
|
||||||
|
if (!string.IsNullOrEmpty(jsonObject["mux"]?.ToString()))
|
||||||
|
{
|
||||||
|
matchedCounter += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return matchedCounter >= 3;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,24 @@ 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, new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
WriteIndented = true,
|
||||||
|
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
|
||||||
|
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||||
|
})
|
||||||
|
: finalmaskDecoded;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
item.Finalmask = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
item.SetProtocolExtra(item.GetProtocolExtra() with
|
item.SetProtocolExtra(item.GetProtocolExtra() with
|
||||||
{
|
{
|
||||||
WgPublicKey = GetQueryDecoded(query, "publickey"),
|
WgPublicKey = GetQueryDecoded(query, "publickey"),
|
||||||
@@ -31,6 +49,7 @@ public class WireguardFmt : BaseFmt
|
|||||||
WgReserved = GetQueryDecoded(query, "reserved"),
|
WgReserved = GetQueryDecoded(query, "reserved"),
|
||||||
WgInterfaceAddress = GetQueryDecoded(query, "address"),
|
WgInterfaceAddress = GetQueryDecoded(query, "address"),
|
||||||
WgMtu = int.TryParse(GetQueryDecoded(query, "mtu"), out var mtuVal) ? mtuVal : null,
|
WgMtu = int.TryParse(GetQueryDecoded(query, "mtu"), out var mtuVal) ? mtuVal : null,
|
||||||
|
WgDns = GetQueryDecoded(query, "dns"),
|
||||||
});
|
});
|
||||||
|
|
||||||
return item;
|
return item;
|
||||||
@@ -71,6 +90,23 @@ public class WireguardFmt : BaseFmt
|
|||||||
{
|
{
|
||||||
dicQuery.Add("mtu", protoExtra.WgMtu.ToString());
|
dicQuery.Add("mtu", protoExtra.WgMtu.ToString());
|
||||||
}
|
}
|
||||||
|
if (!protoExtra.WgDns.IsNullOrEmpty())
|
||||||
|
{
|
||||||
|
dicQuery.Add("dns", Utils.UrlEncode(protoExtra.WgDns));
|
||||||
|
}
|
||||||
|
if (item.Finalmask.IsNotEmpty())
|
||||||
|
{
|
||||||
|
var node = JsonUtils.ParseJson(item.Finalmask);
|
||||||
|
var finalmask = node != null
|
||||||
|
? JsonUtils.Serialize(node, new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
WriteIndented = false,
|
||||||
|
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
|
||||||
|
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||||
|
})
|
||||||
|
: 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,6 +169,7 @@ public class WireguardFmt : BaseFmt
|
|||||||
|
|
||||||
var wgMtu = interfaceDic.TryGetValue("MTU", out var mtuStr) && int.TryParse(mtuStr, out var mtuVal) ? mtuVal : 0;
|
var wgMtu = interfaceDic.TryGetValue("MTU", out var mtuStr) && int.TryParse(mtuStr, out var mtuVal) ? mtuVal : 0;
|
||||||
var wgInterfaceAddress = interfaceDic.TryGetValue("Address", out var interfaceAddress) ? interfaceAddress : string.Empty;
|
var wgInterfaceAddress = interfaceDic.TryGetValue("Address", out var interfaceAddress) ? interfaceAddress : string.Empty;
|
||||||
|
var wgDns = interfaceDic.TryGetValue("DNS", out var dns) ? dns : string.Empty;
|
||||||
|
|
||||||
var index = 0;
|
var index = 0;
|
||||||
var resultList = new List<ProfileItem>();
|
var resultList = new List<ProfileItem>();
|
||||||
@@ -156,6 +193,7 @@ public class WireguardFmt : BaseFmt
|
|||||||
WgInterfaceAddress = wgInterfaceAddress,
|
WgInterfaceAddress = wgInterfaceAddress,
|
||||||
WgReserved = (peerDic.TryGetValue("Reserved", out var reserved) ? reserved : string.Empty).NullIfEmpty(),
|
WgReserved = (peerDic.TryGetValue("Reserved", out var reserved) ? reserved : string.Empty).NullIfEmpty(),
|
||||||
WgMtu = wgMtu > 0 ? wgMtu : null,
|
WgMtu = wgMtu > 0 ? wgMtu : null,
|
||||||
|
WgDns = wgDns,
|
||||||
};
|
};
|
||||||
|
|
||||||
var item = new ProfileItem
|
var item = new ProfileItem
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ public static class SubscriptionHandler
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create download handler
|
// Create download handler
|
||||||
var downloadHandle = CreateDownloadHandler(hashCode, updateFunc);
|
var downloadHandle = CreateDownloadHandler(item, 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)
|
||||||
@@ -80,9 +80,18 @@ public static class SubscriptionHandler
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static DownloadService CreateDownloadHandler(string hashCode, Func<bool, string, Task> updateFunc)
|
private static DownloadService CreateDownloadHandler(SubItem item, string hashCode, Func<bool, string, Task> updateFunc)
|
||||||
{
|
{
|
||||||
var downloadHandle = new DownloadService();
|
if (!HttpRequestHeadersHelper.TryParse(item.RequestHeaders, out var requestHeaders))
|
||||||
|
{
|
||||||
|
throw new FormatException(ResUI.SubRequestHeadersInvalid);
|
||||||
|
}
|
||||||
|
|
||||||
|
var downloadHandle = new DownloadService
|
||||||
|
{
|
||||||
|
AcceptHeader = "*/*",
|
||||||
|
RequestHeaders = requestHeaders
|
||||||
|
};
|
||||||
downloadHandle.Error += (sender2, args) =>
|
downloadHandle.Error += (sender2, args) =>
|
||||||
{
|
{
|
||||||
updateFunc?.Invoke(false, $"{hashCode}{args.GetException().Message}");
|
updateFunc?.Invoke(false, $"{hashCode}{args.GetException().Message}");
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ public static class SysProxyHandler
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var port = AppManager.Instance.GetLocalPort(EInboundProtocol.socks);
|
var port = AppManager.Instance.GetLocalPort(EInboundProtocol.socks);
|
||||||
var exceptions = config.SystemProxyItem.SystemProxyExceptions.Replace(" ", "");
|
|
||||||
if (port <= 0)
|
if (port <= 0)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
@@ -24,17 +23,18 @@ public static class SysProxyHandler
|
|||||||
switch (type)
|
switch (type)
|
||||||
{
|
{
|
||||||
case ESysProxyType.ForcedChange when Utils.IsWindows():
|
case ESysProxyType.ForcedChange when Utils.IsWindows():
|
||||||
{
|
var (strProxy, strExceptions) = GetWindowsProxyString(config, port);
|
||||||
GetWindowsProxyString(config, port, out var strProxy, out var strExceptions);
|
ProxySettingWindows.SetProxy(strProxy, strExceptions, 2);
|
||||||
ProxySettingWindows.SetProxy(strProxy, strExceptions, 2);
|
break;
|
||||||
break;
|
|
||||||
}
|
|
||||||
case ESysProxyType.ForcedChange when Utils.IsLinux():
|
case ESysProxyType.ForcedChange when Utils.IsLinux():
|
||||||
|
var exceptions = SanitizeExceptions(config);
|
||||||
await ProxySettingLinux.SetProxy(Global.Loopback, port, exceptions);
|
await ProxySettingLinux.SetProxy(Global.Loopback, port, exceptions);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case ESysProxyType.ForcedChange when Utils.IsMacOS():
|
case ESysProxyType.ForcedChange when Utils.IsMacOS():
|
||||||
await ProxySettingOSX.SetProxy(Global.Loopback, port, exceptions);
|
var exceptions2 = SanitizeExceptions(config);
|
||||||
|
await ProxySettingOSX.SetProxy(Global.Loopback, port, exceptions2);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case ESysProxyType.ForcedClear when Utils.IsWindows():
|
case ESysProxyType.ForcedClear when Utils.IsWindows():
|
||||||
@@ -66,15 +66,31 @@ public static class SysProxyHandler
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void GetWindowsProxyString(Config config, int port, out string strProxy, out string strExceptions)
|
private static string SanitizeExceptions(Config config)
|
||||||
{
|
{
|
||||||
strExceptions = config.SystemProxyItem.SystemProxyExceptions.Replace(" ", "");
|
var exceptions = config.SystemProxyItem.SystemProxyExceptions;
|
||||||
|
if (exceptions.IsNullOrEmpty())
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
var items = exceptions
|
||||||
|
.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)
|
||||||
|
.Select(item => item.Replace(" ", string.Empty))
|
||||||
|
.Where(item => item.Length > 0);
|
||||||
|
|
||||||
|
return string.Join(',', items);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (string strProxy, string strExceptions) GetWindowsProxyString(Config config, int port)
|
||||||
|
{
|
||||||
|
var strExceptions = config.SystemProxyItem.SystemProxyExceptions.Replace(" ", "");
|
||||||
if (config.SystemProxyItem.NotProxyLocalAddress)
|
if (config.SystemProxyItem.NotProxyLocalAddress)
|
||||||
{
|
{
|
||||||
strExceptions = $"<local>;{strExceptions}";
|
strExceptions = $"<local>;{strExceptions}";
|
||||||
}
|
}
|
||||||
|
|
||||||
strProxy = string.Empty;
|
var strProxy = string.Empty;
|
||||||
if (config.SystemProxyItem.SystemProxyAdvancedProtocol.IsNullOrEmpty())
|
if (config.SystemProxyItem.SystemProxyAdvancedProtocol.IsNullOrEmpty())
|
||||||
{
|
{
|
||||||
strProxy = $"{Global.Loopback}:{port}";
|
strProxy = $"{Global.Loopback}:{port}";
|
||||||
@@ -86,6 +102,8 @@ public static class SysProxyHandler
|
|||||||
.Replace("{http_port}", port.ToString())
|
.Replace("{http_port}", port.ToString())
|
||||||
.Replace("{socks_port}", port.ToString());
|
.Replace("{socks_port}", port.ToString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return (strProxy, strExceptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
[SupportedOSPlatform("windows")]
|
[SupportedOSPlatform("windows")]
|
||||||
|
|||||||
@@ -8,15 +8,14 @@ public class DownloaderHelper
|
|||||||
private static readonly Lazy<DownloaderHelper> _instance = new(() => new());
|
private static readonly Lazy<DownloaderHelper> _instance = new(() => new());
|
||||||
public static DownloaderHelper Instance => _instance.Value;
|
public static DownloaderHelper Instance => _instance.Value;
|
||||||
|
|
||||||
public async Task<string?> DownloadStringAsync(IWebProxy? webProxy, string url, string? userAgent, int timeout)
|
public async Task<string?> DownloadStringAsync(IWebProxy? webProxy, string url, string? userAgent,
|
||||||
|
IReadOnlyDictionary<string, string>? requestHeaders = null, string? acceptHeader = null, 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);
|
Uri uri = new(url);
|
||||||
//Authorization Header
|
//Authorization Header
|
||||||
var headers = new WebHeaderCollection();
|
var headers = new WebHeaderCollection();
|
||||||
@@ -28,16 +27,16 @@ public class DownloaderHelper
|
|||||||
var requestConfiguration = new RequestConfiguration()
|
var requestConfiguration = new RequestConfiguration()
|
||||||
{
|
{
|
||||||
Headers = headers,
|
Headers = headers,
|
||||||
|
Accept = acceptHeader,
|
||||||
UserAgent = userAgent,
|
UserAgent = userAgent,
|
||||||
ConnectTimeout = connectTimeout * 1000,
|
ConnectTimeout = GetConnectTimeoutMs(webProxy != null),
|
||||||
Proxy = webProxy
|
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 = () => HttpRequestHeadersHelper.CreateHandler(GetSocketsHttpHandler(requestConfiguration), requestHeaders),
|
||||||
};
|
};
|
||||||
|
|
||||||
await using var downloader = new Downloader.DownloadService(downloadOpt);
|
await using var downloader = new Downloader.DownloadService(downloadOpt);
|
||||||
@@ -49,33 +48,26 @@ public class DownloaderHelper
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
using var cts = new CancellationTokenSource();
|
await using var stream = await downloader.DownloadFileTaskAsync(address: 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);
|
||||||
|
|
||||||
downloadOpt = null;
|
return await reader.ReadToEndAsync(cancellationToken);
|
||||||
|
|
||||||
return await reader.ReadToEndAsync(cts.Token);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task DownloadDataAsync4Speed(IWebProxy webProxy, string url, IProgress<string> progress, int timeout)
|
public async Task DownloadDataAsync4Speed(IWebProxy webProxy, 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 requestConfiguration = new RequestConfiguration()
|
var requestConfiguration = new RequestConfiguration()
|
||||||
{
|
{
|
||||||
ConnectTimeout = connectTimeout * 1000,
|
ConnectTimeout = GetConnectTimeoutMs(true),
|
||||||
Proxy = webProxy
|
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 = () => GetSocketsHttpHandler(requestConfiguration),
|
||||||
@@ -88,112 +80,186 @@ public class DownloaderHelper
|
|||||||
|
|
||||||
downloader.DownloadProgressChanged += (sender, value) =>
|
downloader.DownloadProgressChanged += (sender, value) =>
|
||||||
{
|
{
|
||||||
if (progress != null && value.BytesPerSecondSpeed > 0)
|
if (!(value.BytesPerSecondSpeed > 0))
|
||||||
{
|
{
|
||||||
hasValue = true;
|
return;
|
||||||
if (value.BytesPerSecondSpeed > maxSpeed)
|
}
|
||||||
{
|
hasValue = true;
|
||||||
maxSpeed = value.BytesPerSecondSpeed;
|
if (value.BytesPerSecondSpeed > maxSpeed)
|
||||||
}
|
{
|
||||||
|
maxSpeed = value.BytesPerSecondSpeed;
|
||||||
|
}
|
||||||
|
|
||||||
var ts = DateTime.Now - lastUpdateTime;
|
var ts = DateTime.Now - lastUpdateTime;
|
||||||
if (ts.TotalMilliseconds >= 1000)
|
if (ts.TotalMilliseconds >= 1000)
|
||||||
{
|
{
|
||||||
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");
|
||||||
{
|
onProgress.Invoke(finalSpeed);
|
||||||
var finalSpeed = (maxSpeed / 1000 / 1000).ToString("#0.0");
|
}
|
||||||
progress.Report(finalSpeed);
|
else if (value.Error != null)
|
||||||
}
|
{
|
||||||
else if (value.Error != null)
|
onProgress.Invoke(value.Error?.Message);
|
||||||
{
|
}
|
||||||
progress.Report(value.Error?.Message);
|
else
|
||||||
}
|
{
|
||||||
else
|
onProgress.Invoke("0");
|
||||||
{
|
|
||||||
progress.Report("0");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
//progress.Report("......");
|
//progress.Invoke("......");
|
||||||
using var cts = new CancellationTokenSource();
|
await using var stream = await downloader.DownloadFileTaskAsync(address: url, cancellationToken);
|
||||||
cts.CancelAfter(TimeSpan.FromSeconds(timeout));
|
|
||||||
await using var stream = await downloader.DownloadFileTaskAsync(address: url, cts.Token);
|
|
||||||
|
|
||||||
downloadOpt = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task DownloadFileAsync(IWebProxy? webProxy, string url, string fileName, IProgress<double> progress, int timeout)
|
public async Task DownloadFileAsync(IWebProxy? webProxy, FileDownloadRequest request, Action<FileDownloadState> onProgress, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (url.IsNullOrEmpty())
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
if (request.FilePath.IsNullOrEmpty())
|
||||||
{
|
{
|
||||||
throw new ArgumentNullException(nameof(url));
|
throw new ArgumentNullException(nameof(request.FilePath));
|
||||||
}
|
}
|
||||||
if (fileName.IsNullOrEmpty())
|
if (File.Exists(request.FilePath))
|
||||||
{
|
{
|
||||||
throw new ArgumentNullException(nameof(fileName));
|
File.Delete(request.FilePath);
|
||||||
}
|
|
||||||
if (File.Exists(fileName))
|
|
||||||
{
|
|
||||||
File.Delete(fileName);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var connectTimeout = Math.Clamp(timeout / 5, 2, 5);
|
var state = new FileDownloadState
|
||||||
var requestConfiguration = new RequestConfiguration()
|
|
||||||
{
|
{
|
||||||
ConnectTimeout = connectTimeout * 1000,
|
Request = request,
|
||||||
Proxy = webProxy
|
};
|
||||||
|
|
||||||
|
var requestConfiguration = new RequestConfiguration
|
||||||
|
{
|
||||||
|
ConnectTimeout = GetConnectTimeoutMs(webProxy != null),
|
||||||
|
Proxy = webProxy,
|
||||||
};
|
};
|
||||||
var downloadOpt = new DownloadConfiguration()
|
var downloadOpt = new DownloadConfiguration()
|
||||||
{
|
{
|
||||||
BlockTimeout = timeout * 1000,
|
ChunkCount = 100,
|
||||||
MaxTryAgainOnFailure = 2,
|
MinimumChunkSize = 8 * 1024 * 1024, // 8 MB
|
||||||
|
MinimumSizeOfChunking = 8 * 1024 * 1024, // 8 MB
|
||||||
|
ParallelDownload = true,
|
||||||
|
ParallelCount = 4,
|
||||||
|
|
||||||
RequestConfiguration = requestConfiguration,
|
RequestConfiguration = requestConfiguration,
|
||||||
CustomHttpMessageHandlerFactory = () => GetSocketsHttpHandler(requestConfiguration),
|
CustomHttpMessageHandlerFactory = () => GetSocketsHttpHandler(requestConfiguration),
|
||||||
};
|
};
|
||||||
|
|
||||||
var progressPercentage = 0;
|
|
||||||
var hasValue = false;
|
|
||||||
await using var downloader = new Downloader.DownloadService(downloadOpt);
|
await using var downloader = new Downloader.DownloadService(downloadOpt);
|
||||||
downloader.DownloadStarted += (sender, value) => progress?.Report(0);
|
downloader.DownloadStarted += (sender, value) =>
|
||||||
|
{
|
||||||
|
state = state with
|
||||||
|
{
|
||||||
|
TotalBytes = value.TotalBytesToReceive,
|
||||||
|
};
|
||||||
|
onProgress.Invoke(state);
|
||||||
|
};
|
||||||
downloader.DownloadProgressChanged += (sender, value) =>
|
downloader.DownloadProgressChanged += (sender, value) =>
|
||||||
{
|
{
|
||||||
hasValue = true;
|
state = state with
|
||||||
var percent = (int)value.ProgressPercentage;// Convert.ToInt32((totalRead * 1d) / (total * 1d) * 100);
|
|
||||||
if (progressPercentage != percent && percent % 10 == 0)
|
|
||||||
{
|
{
|
||||||
progressPercentage = percent;
|
DownloadedBytes = value.ReceivedBytesSize,
|
||||||
progress.Report(percent);
|
TotalBytes = value.TotalBytesToReceive,
|
||||||
}
|
SpeedBytesPerSecond = value.BytesPerSecondSpeed,
|
||||||
|
};
|
||||||
|
onProgress.Invoke(state);
|
||||||
};
|
};
|
||||||
downloader.DownloadFileCompleted += (sender, value) =>
|
downloader.DownloadFileCompleted += (sender, value) =>
|
||||||
{
|
{
|
||||||
if (progress != null)
|
state = state with
|
||||||
{
|
{
|
||||||
if (hasValue && value.Error == null)
|
Completed = true,
|
||||||
{
|
Error = value.Error,
|
||||||
progress.Report(101);
|
};
|
||||||
}
|
onProgress.Invoke(state);
|
||||||
else if (value.Error != null)
|
|
||||||
{
|
|
||||||
throw value.Error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
using var cts = new CancellationTokenSource();
|
await downloader.DownloadFileTaskAsync(request.FileUrl, request.FilePath, cancellationToken);
|
||||||
await downloader.DownloadFileTaskAsync(url, fileName, cts.Token);
|
}
|
||||||
|
|
||||||
downloadOpt = null;
|
public async Task DownloadSmallFilesAsync(IWebProxy? webProxy, List<FileDownloadRequest> requests, Action<ReadOnlyMemory<FileDownloadState>> onProgress, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (requests is not { Count: > 0 })
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(requests));
|
||||||
|
}
|
||||||
|
|
||||||
|
var states = new FileDownloadState[requests.Count];
|
||||||
|
for (var i = 0; i < requests.Count; i++)
|
||||||
|
{
|
||||||
|
states[i] = new FileDownloadState
|
||||||
|
{
|
||||||
|
Request = requests[i],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
var readOnlyStates = new ReadOnlyMemory<FileDownloadState>(states);
|
||||||
|
|
||||||
|
var requestConfiguration = new RequestConfiguration()
|
||||||
|
{
|
||||||
|
ConnectTimeout = GetConnectTimeoutMs(webProxy != null),
|
||||||
|
Proxy = webProxy,
|
||||||
|
|
||||||
|
KeepAlive = true,
|
||||||
|
};
|
||||||
|
using var socketsHttpHandler = GetSocketsHttpHandler(requestConfiguration);
|
||||||
|
|
||||||
|
var parallelOptions = new ParallelOptions
|
||||||
|
{
|
||||||
|
MaxDegreeOfParallelism = 4,
|
||||||
|
CancellationToken = cancellationToken,
|
||||||
|
};
|
||||||
|
|
||||||
|
await Parallel.ForEachAsync(Enumerable.Range(0, requests.Count), parallelOptions, async (index, parallelCancellationToken) =>
|
||||||
|
{
|
||||||
|
var request = requests[index];
|
||||||
|
var downloadOpt = new DownloadConfiguration()
|
||||||
|
{
|
||||||
|
RequestConfiguration = requestConfiguration,
|
||||||
|
// ReSharper disable once AccessToDisposedClosure
|
||||||
|
CustomHttpMessageHandlerFactory = () => socketsHttpHandler,
|
||||||
|
};
|
||||||
|
await using var downloader = new Downloader.DownloadService(downloadOpt);
|
||||||
|
downloader.DownloadStarted += (sender, value) =>
|
||||||
|
{
|
||||||
|
states[index] = states[index] with
|
||||||
|
{
|
||||||
|
DownloadedBytes = 0,
|
||||||
|
TotalBytes = value.TotalBytesToReceive,
|
||||||
|
SpeedBytesPerSecond = 0,
|
||||||
|
Completed = false,
|
||||||
|
};
|
||||||
|
onProgress.Invoke(readOnlyStates);
|
||||||
|
};
|
||||||
|
downloader.DownloadProgressChanged += (sender, value) =>
|
||||||
|
{
|
||||||
|
states[index] = states[index] with
|
||||||
|
{
|
||||||
|
DownloadedBytes = value.ReceivedBytesSize,
|
||||||
|
TotalBytes = value.TotalBytesToReceive,
|
||||||
|
SpeedBytesPerSecond = value.BytesPerSecondSpeed,
|
||||||
|
Completed = false,
|
||||||
|
};
|
||||||
|
onProgress.Invoke(readOnlyStates);
|
||||||
|
};
|
||||||
|
downloader.DownloadFileCompleted += (sender, value) =>
|
||||||
|
{
|
||||||
|
var newState = states[index] with { Completed = true };
|
||||||
|
if (value.Error != null)
|
||||||
|
{
|
||||||
|
newState = newState with { Error = value.Error };
|
||||||
|
}
|
||||||
|
states[index] = newState;
|
||||||
|
onProgress.Invoke(readOnlyStates);
|
||||||
|
};
|
||||||
|
await downloader.DownloadFileTaskAsync(request.FileUrl, request.FilePath, parallelCancellationToken);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// https://github.com/bezzad/Downloader/blob/a75a6e431acd6cbba6293f7afdcf676544a09174/src/Downloader/SocketClient.cs#L45
|
// https://github.com/bezzad/Downloader/blob/a75a6e431acd6cbba6293f7afdcf676544a09174/src/Downloader/SocketClient.cs#L45
|
||||||
@@ -213,7 +279,7 @@ public class DownloaderHelper
|
|||||||
PooledConnectionIdleTimeout = config.KeepAliveTimeout,
|
PooledConnectionIdleTimeout = config.KeepAliveTimeout,
|
||||||
PooledConnectionLifetime = Timeout.InfiniteTimeSpan,
|
PooledConnectionLifetime = Timeout.InfiniteTimeSpan,
|
||||||
EnableMultipleHttp2Connections = true,
|
EnableMultipleHttp2Connections = true,
|
||||||
ConnectTimeout = TimeSpan.FromMilliseconds(config.ConnectTimeout)
|
ConnectTimeout = TimeSpan.FromMilliseconds(config.ConnectTimeout),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Set up the SslClientAuthenticationOptions for custom certificate validation
|
// Set up the SslClientAuthenticationOptions for custom certificate validation
|
||||||
@@ -266,4 +332,9 @@ public class DownloaderHelper
|
|||||||
|
|
||||||
return handler;
|
return handler;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private int GetConnectTimeoutMs(bool isProxy)
|
||||||
|
{
|
||||||
|
return (int)(isProxy ? Global.ProxyDownloadConnect : Global.DirectDownloadConnect).TotalMilliseconds;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
{
|
{
|
||||||
@@ -281,6 +279,37 @@ public class CertPemManager
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static string GetLeafCertSha256Thumbprint(string pemCertChain, bool includeColon = false)
|
||||||
|
{
|
||||||
|
var certs = ParsePemChain(pemCertChain);
|
||||||
|
if (certs.Count == 0)
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
foreach (var certStr in certs)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var cert = X509Certificate2.CreateFromPem(certStr);
|
||||||
|
var extension = cert.Extensions
|
||||||
|
.OfType<X509BasicConstraintsExtension>()
|
||||||
|
.FirstOrDefault();
|
||||||
|
var isCa = extension?.CertificateAuthority == true;
|
||||||
|
if (isCa)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
var thumbprint = cert.GetCertHashString(HashAlgorithmName.SHA256);
|
||||||
|
return includeColon ? string.Join(":", thumbprint.Chunk(2).Select(c => new string(c))) : thumbprint;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
private static readonly Lazy<X509Certificate2Collection> _chromeRootCerts = new(() =>
|
private static readonly Lazy<X509Certificate2Collection> _chromeRootCerts = new(() =>
|
||||||
{
|
{
|
||||||
var pemText = EmbedUtils.GetEmbedText(Global.ChromeRootCertFileName);
|
var pemText = EmbedUtils.GetEmbedText(Global.ChromeRootCertFileName);
|
||||||
|
|||||||
@@ -1,113 +1,104 @@
|
|||||||
using static ServiceLib.Models.Dto.ClashProxies;
|
|
||||||
|
|
||||||
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()
|
||||||
{
|
{
|
||||||
for (var i = 0; i < 3; i++)
|
for (var i = 0; i < 3; i++)
|
||||||
{
|
{
|
||||||
var url = $"{GetApiUrl()}/proxies";
|
var url = $"{ApiUrl}/proxies";
|
||||||
var result = await HttpClientHelper.Instance.TryGetAsync(url);
|
var resultTask = HttpClientHelper.Instance.TryGetAsync(url);
|
||||||
var clashProxies = JsonUtils.Deserialize<ClashProxies>(result);
|
|
||||||
|
|
||||||
var url2 = $"{GetApiUrl()}/providers/proxies";
|
var url2 = $"{ApiUrl}/providers/proxies";
|
||||||
var result2 = await HttpClientHelper.Instance.TryGetAsync(url2);
|
var result2Task = HttpClientHelper.Instance.TryGetAsync(url2);
|
||||||
|
|
||||||
|
await Task.WhenAll(resultTask, result2Task);
|
||||||
|
|
||||||
|
var result = await resultTask;
|
||||||
|
var result2 = await result2Task;
|
||||||
|
var clashProxies = JsonUtils.Deserialize<ClashProxies>(result);
|
||||||
var clashProviders = JsonUtils.Deserialize<ClashProviders>(result2);
|
var clashProviders = JsonUtils.Deserialize<ClashProviders>(result2);
|
||||||
|
|
||||||
if (clashProxies != null || clashProviders != null)
|
if (clashProxies is null && clashProviders is null)
|
||||||
{
|
{
|
||||||
_proxies = clashProxies?.proxies;
|
await Task.Delay(2000);
|
||||||
return new Tuple<ClashProxies, ClashProviders>(clashProxies, clashProviders);
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
await Task.Delay(2000);
|
var item = new ClashItem();
|
||||||
|
if (clashProxies?.proxies is { Count: > 0 } proxies)
|
||||||
|
{
|
||||||
|
item = item with { Proxies = proxies };
|
||||||
|
}
|
||||||
|
if (clashProviders.providers is { Count: > 0 } providers)
|
||||||
|
{
|
||||||
|
foreach (var provider in providers)
|
||||||
|
{
|
||||||
|
foreach (var proxy in provider.Value.proxies ?? [])
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(proxy.name)
|
||||||
|
|| !item.Proxies.TryAdd(proxy.name, proxy))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
item.ProviderIndexMap.Add(proxy.name, provider.Key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ClashProxiesDelayTest(bool blAll, List<ClashProxyModel> lstProxy, Func<ClashProxyModel?, string, Task> updateFunc)
|
public async Task<int> TestDelay(string name, ClashItem? clashItem)
|
||||||
{
|
{
|
||||||
Task.Run(async () =>
|
if (clashItem?.ProviderIndexMap.TryGetValue(name, out var providerName) == true)
|
||||||
{
|
{
|
||||||
if (blAll)
|
return await TestProviderProxyDelay(name, providerName);
|
||||||
{
|
}
|
||||||
if (_proxies == null)
|
return await TestProxyDelay(name);
|
||||||
{
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
var name = it.Name;
|
|
||||||
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()
|
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)
|
||||||
|
{
|
||||||
|
var result = await HttpClientHelper.Instance.TryGetAsync(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;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SetActiveProxy(string groupName, string nodeName)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var fileContent = ProfileContent;
|
var url = $"{ApiUrl}/proxies/{Utils.UrlEncode(groupName)}";
|
||||||
if (fileContent is null || fileContent?.ContainsKey("proxy-groups") == false)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return JsonUtils.Deserialize<List<ProxiesItem>>(JsonUtils.Serialize(fileContent["proxy-groups"]));
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Logging.SaveLog(_tag, ex);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task ClashSetActiveProxy(string name, string nameNode)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var url = $"{GetApiUrl()}/proxies/{name}";
|
|
||||||
var headers = new Dictionary<string, string>();
|
var headers = new Dictionary<string, string>();
|
||||||
headers.Add("name", nameNode);
|
headers.Add("name", nodeName);
|
||||||
await HttpClientHelper.Instance.PutAsync(url, headers);
|
await HttpClientHelper.Instance.PutAsync(url, headers);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -116,39 +107,67 @@ public sealed class ClashApiManager
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task ClashConfigUpdate(Dictionary<string, string> headers)
|
public async Task UpdateClashMode(string mode)
|
||||||
{
|
{
|
||||||
if (_proxies == null)
|
var headers = new Dictionary<string, string>
|
||||||
{
|
{
|
||||||
return;
|
{ "mode", mode },
|
||||||
}
|
};
|
||||||
|
await UpdateConfig(headers);
|
||||||
|
}
|
||||||
|
|
||||||
var urlBase = $"{GetApiUrl()}/configs";
|
public async Task UpdateConfig(Dictionary<string, string> headers)
|
||||||
|
{
|
||||||
|
var urlBase = $"{ApiUrl}/configs";
|
||||||
|
|
||||||
await HttpClientHelper.Instance.PatchAsync(urlBase, headers);
|
await HttpClientHelper.Instance.PatchAsync(urlBase, headers);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task ClashConfigReload(string filePath)
|
public async Task<List<string>> GetClashModes()
|
||||||
{
|
{
|
||||||
await ClashConnectionClose("");
|
var jsonNode = await GetConfig();
|
||||||
try
|
if ((jsonNode?["mode-list"] ?? jsonNode?["modes"]) is not JsonArray { Count: > 0 } modesArray)
|
||||||
{
|
{
|
||||||
var url = $"{GetApiUrl()}/configs?force=true";
|
return [];
|
||||||
var headers = new Dictionary<string, string>();
|
|
||||||
headers.Add("path", filePath);
|
|
||||||
await HttpClientHelper.Instance.PutAsync(url, headers);
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
var modes = new List<string>();
|
||||||
|
foreach (var mode in modesArray)
|
||||||
{
|
{
|
||||||
Logging.SaveLog(_tag, ex);
|
if (mode is JsonValue jsonValue && jsonValue.GetValueKind() == JsonValueKind.String)
|
||||||
|
{
|
||||||
|
modes.Add(jsonValue.GetValue<string>());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return modes;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ClashConnections?> GetClashConnectionsAsync()
|
public async Task<string?> GetClashMode()
|
||||||
|
{
|
||||||
|
var jsonNode = await GetConfig();
|
||||||
|
if (jsonNode["mode"] is not JsonValue jsonValue || jsonValue.GetValueKind() != JsonValueKind.String)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return jsonValue.GetValue<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<JsonObject> GetConfig()
|
||||||
|
{
|
||||||
|
var url = $"{ApiUrl}/configs";
|
||||||
|
var result = await HttpClientHelper.Instance.TryGetAsync(url);
|
||||||
|
var jsonNode = JsonUtils.ParseJson(result);
|
||||||
|
if (jsonNode is not JsonObject jsonObject)
|
||||||
|
{
|
||||||
|
return new JsonObject();
|
||||||
|
}
|
||||||
|
return jsonObject;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ClashConnections?> GetConnections()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var url = $"{GetApiUrl()}/connections";
|
var url = $"{ApiUrl}/connections";
|
||||||
var result = await HttpClientHelper.Instance.TryGetAsync(url);
|
var result = await HttpClientHelper.Instance.TryGetAsync(url);
|
||||||
var clashConnections = JsonUtils.Deserialize<ClashConnections>(result);
|
var clashConnections = JsonUtils.Deserialize<ClashConnections>(result);
|
||||||
|
|
||||||
@@ -162,11 +181,11 @@ 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);
|
await HttpClientHelper.Instance.DeleteAsync(url);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -174,9 +193,4 @@ public sealed class ClashApiManager
|
|||||||
Logging.SaveLog(_tag, ex);
|
Logging.SaveLog(_tag, ex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private string GetApiUrl()
|
|
||||||
{
|
|
||||||
return $"{Global.HttpProtocol}{Global.Loopback}:{AppManager.Instance.StatePort2}";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,6 +91,8 @@ public class CoreAdminManager
|
|||||||
.ExecuteBufferedAsync();
|
.ExecuteBufferedAsync();
|
||||||
|
|
||||||
await UpdateFunc(false, result.StandardOutput.ToString());
|
await UpdateFunc(false, result.StandardOutput.ToString());
|
||||||
|
|
||||||
|
await Task.Delay(1000); // Wait for a second to ensure the process is killed
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ public class CoreManager
|
|||||||
await CoreStop();
|
await CoreStop();
|
||||||
await Task.Delay(100);
|
await Task.Delay(100);
|
||||||
|
|
||||||
if (Utils.IsWindows() && _config.TunModeItem.EnableTun)
|
if (Utils.IsWindows() && (mainContext?.IsTunEnabled == true || preContext?.IsTunEnabled == true))
|
||||||
{
|
{
|
||||||
await Task.Delay(100);
|
await Task.Delay(100);
|
||||||
await WindowsUtils.RemoveTunDevice();
|
await WindowsUtils.RemoveTunDevice();
|
||||||
@@ -183,7 +183,7 @@ public class CoreManager
|
|||||||
var coreInfo = CoreInfoManager.Instance.GetCoreInfo(coreType);
|
var coreInfo = CoreInfoManager.Instance.GetCoreInfo(coreType);
|
||||||
|
|
||||||
var displayLog = node.ConfigType != EConfigType.Custom || node.DisplayLog;
|
var displayLog = node.ConfigType != EConfigType.Custom || node.DisplayLog;
|
||||||
var proc = await RunProcess(coreInfo, Global.CoreConfigFileName, displayLog, true);
|
var proc = await RunProcess(coreInfo, Global.CoreConfigFileName, displayLog, true, context.IsTunEnabled);
|
||||||
if (proc is null)
|
if (proc is null)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
@@ -201,7 +201,7 @@ public class CoreManager
|
|||||||
if (result.Success)
|
if (result.Success)
|
||||||
{
|
{
|
||||||
var coreInfo = CoreInfoManager.Instance.GetCoreInfo(preCoreType);
|
var coreInfo = CoreInfoManager.Instance.GetCoreInfo(preCoreType);
|
||||||
var proc = await RunProcess(coreInfo, Global.CorePreConfigFileName, true, true);
|
var proc = await RunProcess(coreInfo, Global.CorePreConfigFileName, true, true, preContext.IsTunEnabled);
|
||||||
if (proc is null)
|
if (proc is null)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
@@ -216,18 +216,18 @@ 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)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!preContext.AppConfig.TunModeItem.EnableTun)
|
if (!preContext.IsTunEnabled)
|
||||||
{
|
{
|
||||||
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;
|
||||||
@@ -289,7 +289,20 @@ public class CoreManager
|
|||||||
|
|
||||||
#region Process
|
#region Process
|
||||||
|
|
||||||
private async Task<ProcessService?> RunProcess(CoreInfo? coreInfo, string configPath, bool displayLog, bool mayNeedSudo)
|
/// <summary>
|
||||||
|
/// Decides whether a core launch must be elevated on non-Windows platforms.
|
||||||
|
/// The TUN state comes from the immutable <see cref="CoreConfigContext" /> snapshot that
|
||||||
|
/// generated the config, never from the live mutable config: the generated config and the
|
||||||
|
/// launch mode must always agree, even if TUN is toggled while a reload is in flight.
|
||||||
|
/// </summary>
|
||||||
|
public static bool ShouldRunAsSudo(bool isTunLaunch, ECoreType? coreType, bool isNonWindows)
|
||||||
|
{
|
||||||
|
return isTunLaunch
|
||||||
|
&& coreType is ECoreType.sing_box or ECoreType.mihomo or ECoreType.Xray
|
||||||
|
&& isNonWindows;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ProcessService?> RunProcess(CoreInfo? coreInfo, string configPath, bool displayLog, bool mayNeedSudo, bool isTunLaunch = false)
|
||||||
{
|
{
|
||||||
var fileName = CoreInfoManager.Instance.GetCoreExecFile(coreInfo, out var msg);
|
var fileName = CoreInfoManager.Instance.GetCoreExecFile(coreInfo, out var msg);
|
||||||
if (fileName.IsNullOrEmpty())
|
if (fileName.IsNullOrEmpty())
|
||||||
@@ -301,9 +314,7 @@ public class CoreManager
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (mayNeedSudo
|
if (mayNeedSudo
|
||||||
&& _config.TunModeItem.EnableTun
|
&& ShouldRunAsSudo(isTunLaunch, coreInfo.CoreType, Utils.IsNonWindows()))
|
||||||
&& (coreInfo.CoreType is ECoreType.sing_box or ECoreType.mihomo or ECoreType.Xray)
|
|
||||||
&& Utils.IsNonWindows())
|
|
||||||
{
|
{
|
||||||
_linuxSudo = true;
|
_linuxSudo = true;
|
||||||
await CoreAdminManager.Instance.Init(_config, _updateFunc);
|
await CoreAdminManager.Instance.Init(_config, _updateFunc);
|
||||||
|
|||||||
@@ -118,8 +118,8 @@ public class GroupProfileManager
|
|||||||
return childProfiles?.Where(p =>
|
return childProfiles?.Where(p =>
|
||||||
p != null &&
|
p != null &&
|
||||||
p.IsValid() &&
|
p.IsValid() &&
|
||||||
!p.ConfigType.IsComplexType() &&
|
(!p.ConfigType.IsComplexType() || p.ConfigType == EConfigType.Outbound) &&
|
||||||
(extra.Filter.IsNullOrEmpty() || Regex.IsMatch(p.Remarks, extra.Filter))
|
Utils.IsRegexMatch(p.Remarks, extra.Filter)
|
||||||
)
|
)
|
||||||
.ToList() ?? [];
|
.ToList() ?? [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
//using System.Reactive.Linq;
|
|
||||||
|
|
||||||
namespace ServiceLib.Manager;
|
namespace ServiceLib.Manager;
|
||||||
|
|
||||||
public class ProfileExManager
|
public class ProfileExManager
|
||||||
|
|||||||
@@ -12,7 +12,11 @@ public class TaskManager
|
|||||||
_config = config;
|
_config = config;
|
||||||
_updateFunc = updateFunc;
|
_updateFunc = updateFunc;
|
||||||
|
|
||||||
Task.Run(ScheduledTasks);
|
_ = Task.Factory.StartNew(
|
||||||
|
ScheduledTasks,
|
||||||
|
CancellationToken.None,
|
||||||
|
TaskCreationOptions.LongRunning,
|
||||||
|
TaskScheduler.Default);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ScheduledTasks()
|
private async Task ScheduledTasks()
|
||||||
@@ -57,8 +61,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
|
||||||
{
|
{
|
||||||
@@ -136,7 +140,7 @@ public class TaskManager
|
|||||||
|
|
||||||
var updateService = new UpdateService(_config, async (success, msg) => await Task.CompletedTask);
|
var updateService = new UpdateService(_config, async (success, msg) => await Task.CompletedTask);
|
||||||
|
|
||||||
var msgs = await updateService.CheckHasUpdateOnlyAll(_config.CheckUpdateItem.CheckPreReleaseUpdate);
|
var msgs = await updateService.CheckHasUpdateOnlyAll(_config.CheckUpdateItem.CheckPreReleaseUpdate, _config.CheckUpdateItem.UpdateViaProxy);
|
||||||
foreach (var msg in msgs)
|
foreach (var msg in msgs)
|
||||||
{
|
{
|
||||||
await _updateFunc?.Invoke(false, msg);
|
await _updateFunc?.Invoke(false, msg);
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ public class Config
|
|||||||
public List<KeyEventItem> GlobalHotkeys { get; set; }
|
public List<KeyEventItem> GlobalHotkeys { get; set; }
|
||||||
public List<CoreTypeItem> CoreTypeItem { get; set; }
|
public List<CoreTypeItem> CoreTypeItem { get; set; }
|
||||||
public SimpleDNSItem SimpleDNSItem { get; set; }
|
public SimpleDNSItem SimpleDNSItem { get; set; }
|
||||||
|
public HappyEyeballs4RayItem HappyEyeballs4RayItem { get; set; }
|
||||||
|
|
||||||
#endregion other entities
|
#endregion other entities
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -147,8 +147,10 @@ public class TunModeItem
|
|||||||
public int Mtu { get; set; }
|
public int Mtu { get; set; }
|
||||||
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; }
|
public bool EnableLegacyProtect { get; set; } = true;
|
||||||
public List<string>? RouteExcludeAddress { get; set; }
|
public List<string>? RouteExcludeAddress { get; set; }
|
||||||
|
public string IPv4Address { get; set; }
|
||||||
|
public string IPv6Address { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
[Serializable]
|
[Serializable]
|
||||||
@@ -207,12 +209,11 @@ public class HysteriaItem
|
|||||||
[Serializable]
|
[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; }
|
||||||
@@ -242,6 +243,7 @@ public class WebDavItem
|
|||||||
public class CheckUpdateItem
|
public class CheckUpdateItem
|
||||||
{
|
{
|
||||||
public bool CheckPreReleaseUpdate { get; set; }
|
public bool CheckPreReleaseUpdate { get; set; }
|
||||||
|
public bool UpdateViaProxy { get; set; } = true;
|
||||||
public List<string>? SelectedCoreTypes { get; set; }
|
public List<string>? SelectedCoreTypes { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,9 +251,15 @@ public class CheckUpdateItem
|
|||||||
public class Fragment4RayItem
|
public class Fragment4RayItem
|
||||||
{
|
{
|
||||||
public string? Packets { get; set; }
|
public string? Packets { get; set; }
|
||||||
public string? Length { get; set; }
|
public List<string>? Lengths { get; set; }
|
||||||
public string? Interval { get; set; }
|
public List<string>? Delays { get; set; }
|
||||||
public string? MaxSplit { get; set; }
|
public string? MaxSplit { get; set; }
|
||||||
|
|
||||||
|
// For migration from old version, remove those properties in the future
|
||||||
|
public string? Length { get; set; }
|
||||||
|
|
||||||
|
public string? Interval { get; set; }
|
||||||
|
// migration end
|
||||||
}
|
}
|
||||||
|
|
||||||
[Serializable]
|
[Serializable]
|
||||||
@@ -269,14 +277,27 @@ public class SimpleDNSItem
|
|||||||
public bool? AddCommonHosts { get; set; }
|
public bool? AddCommonHosts { get; set; }
|
||||||
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 bool? BlockBindingQuery { get; set; }
|
public bool? BlockBindingQuery { get; set; }
|
||||||
|
public bool? BlockAAAAQuery { get; set; }
|
||||||
public string? DirectDNS { get; set; }
|
public string? DirectDNS { get; set; }
|
||||||
public string? RemoteDNS { get; set; }
|
public string? RemoteDNS { get; set; }
|
||||||
public string? BootstrapDNS { get; set; }
|
public string? BootstrapDNS { get; set; }
|
||||||
public string? Strategy4Freedom { get; set; }
|
public string? Strategy4Freedom { get; set; }
|
||||||
public string? Strategy4Proxy { get; set; }
|
public string? Strategy4Proxy { get; set; }
|
||||||
|
public string? Strategy4ProxyDial { get; set; }
|
||||||
public bool? ServeStale { get; set; }
|
public bool? ServeStale { get; set; }
|
||||||
public bool? ParallelQuery { get; set; }
|
public bool? ParallelQuery { get; set; }
|
||||||
public string? Hosts { get; set; }
|
public string? Hosts { get; set; }
|
||||||
public string? DirectExpectedIPs { get; set; }
|
public string? DirectExpectedIPs { get; set; }
|
||||||
|
public bool? EnableHappyEyeballs { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Serializable]
|
||||||
|
public class HappyEyeballs4RayItem
|
||||||
|
{
|
||||||
|
public int? TryDelayMs { get; set; }
|
||||||
|
public bool? PrioritizeIPv6 { get; set; }
|
||||||
|
public int? Interleave { get; set; }
|
||||||
|
public int? MaxConcurrentTry { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ public record CoreConfigContext
|
|||||||
public Config AppConfig { get; init; } = new();
|
public Config AppConfig { get; init; } = new();
|
||||||
public FullConfigTemplateItem? FullConfigTemplate { get; init; } = new();
|
public FullConfigTemplateItem? FullConfigTemplate { get; init; } = new();
|
||||||
|
|
||||||
|
public Dictionary<string, string> CustomOutboundContent { get; init; } = new();
|
||||||
|
|
||||||
// Test ServerTestItem Map
|
// Test ServerTestItem Map
|
||||||
public Dictionary<string, string> ServerTestItemMap { get; init; } = new();
|
public Dictionary<string, string> ServerTestItemMap { get; init; } = new();
|
||||||
|
|
||||||
@@ -22,4 +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; }
|
||||||
|
|
||||||
|
// 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
|
||||||
|
public Dictionary<object, string> CustomOutboundMap { get; init; } = new();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,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; }
|
||||||
@@ -94,6 +96,11 @@ 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]
|
[Serializable]
|
||||||
@@ -108,7 +115,6 @@ 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 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; }
|
||||||
@@ -318,10 +324,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; }
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ public class V2rayConfig
|
|||||||
{
|
{
|
||||||
public Log4Ray log { get; set; }
|
public Log4Ray log { get; set; }
|
||||||
public object dns { get; set; }
|
public object dns { get; set; }
|
||||||
|
public FakeDns4Ray? 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; }
|
||||||
@@ -43,6 +44,12 @@ public class Log4Ray
|
|||||||
public string? loglevel { get; set; }
|
public string? loglevel { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class FakeDns4Ray
|
||||||
|
{
|
||||||
|
public string? ipPool { get; set; }
|
||||||
|
public long? poolSize { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
public class Inbounds4Ray
|
public class Inbounds4Ray
|
||||||
{
|
{
|
||||||
public string tag { get; set; }
|
public string tag { get; set; }
|
||||||
@@ -68,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; }
|
||||||
@@ -89,21 +94,6 @@ public class Inboundsettings4Ray
|
|||||||
public List<string>? dns { get; set; }
|
public List<string>? dns { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class UsersItem4Ray
|
|
||||||
{
|
|
||||||
public string? id { get; set; }
|
|
||||||
|
|
||||||
public int? alterId { 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
|
||||||
{
|
{
|
||||||
public bool enabled { get; set; }
|
public bool enabled { get; set; }
|
||||||
@@ -130,14 +120,8 @@ 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 string? domainStrategy { get; set; }
|
|
||||||
|
|
||||||
public int? userLevel { get; set; }
|
public int? userLevel { get; set; }
|
||||||
|
|
||||||
public string? secretKey { get; set; }
|
public string? secretKey { get; set; }
|
||||||
@@ -167,29 +151,18 @@ public class Outboundsettings4Ray
|
|||||||
public int? workers { get; set; }
|
public int? workers { get; set; }
|
||||||
|
|
||||||
public int? version { get; set; }
|
public int? version { get; set; }
|
||||||
}
|
|
||||||
|
|
||||||
public class WireguardPeer4Ray
|
public List<string>? remoteDNS { get; set; }
|
||||||
{
|
|
||||||
public string endpoint { get; set; }
|
|
||||||
public string publicKey { get; set; }
|
|
||||||
public string? preSharedKey { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public class VnextItem4Ray
|
public string? id { get; set; }
|
||||||
{
|
|
||||||
public string address { get; set; }
|
|
||||||
|
|
||||||
public int port { get; set; }
|
public int? alterId { get; set; }
|
||||||
|
|
||||||
public List<UsersItem4Ray> users { get; set; }
|
public string? security { get; set; }
|
||||||
}
|
|
||||||
|
|
||||||
public class ServersItem4Ray
|
public string? encryption { get; set; }
|
||||||
{
|
|
||||||
public string email { get; set; }
|
|
||||||
|
|
||||||
public string address { get; set; }
|
public string? flow { get; set; }
|
||||||
|
|
||||||
public string? method { get; set; }
|
public string? method { get; set; }
|
||||||
|
|
||||||
@@ -197,24 +170,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
|
||||||
@@ -236,6 +199,7 @@ public class Dns4Ray
|
|||||||
public List<object> servers { get; set; }
|
public List<object> 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? tag { get; set; }
|
public string? tag { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,8 +323,6 @@ public class StreamSettings4Ray
|
|||||||
|
|
||||||
public class TlsSettings4Ray
|
public class TlsSettings4Ray
|
||||||
{
|
{
|
||||||
public bool? allowInsecure { get; set; }
|
|
||||||
|
|
||||||
public string? serverName { get; set; }
|
public string? serverName { get; set; }
|
||||||
|
|
||||||
public List<string>? alpn { get; set; }
|
public List<string>? alpn { get; set; }
|
||||||
@@ -516,6 +478,8 @@ public class MaskSettings4Ray
|
|||||||
|
|
||||||
public string? length { get; set; }
|
public string? length { get; set; }
|
||||||
public string? delay { get; set; }
|
public string? delay { get; set; }
|
||||||
|
public List<string>? lengths { get; set; }
|
||||||
|
public List<string>? delays { get; set; }
|
||||||
public int? maxSplit { get; set; }
|
public int? maxSplit { get; set; }
|
||||||
|
|
||||||
// noise
|
// noise
|
||||||
@@ -547,15 +511,20 @@ public class AccountsItem4Ray
|
|||||||
|
|
||||||
public class Sockopt4Ray
|
public class Sockopt4Ray
|
||||||
{
|
{
|
||||||
|
public string? domainStrategy { get; set; }
|
||||||
|
|
||||||
public string? dialerProxy { get; set; }
|
public string? dialerProxy { get; set; }
|
||||||
|
|
||||||
[JsonPropertyName("interface")]
|
[JsonPropertyName("interface")]
|
||||||
public string? Interface { get; set; }
|
public string? Interface { get; set; }
|
||||||
|
|
||||||
|
public HappyEyeballs4Ray? happyEyeballs { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class FragmentItem4Ray
|
public class HappyEyeballs4Ray
|
||||||
{
|
{
|
||||||
public string? packets { get; set; }
|
public int? tryDelayMs { get; set; }
|
||||||
public string? length { get; set; }
|
public bool? prioritizeIPv6 { get; set; }
|
||||||
public string? interval { get; set; }
|
public int? interleave { get; set; }
|
||||||
|
public int? maxConcurrentTry { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace ServiceLib.Models.Dto;
|
||||||
|
|
||||||
|
public sealed record AvailabilityCheckResult(int Time, string Ip)
|
||||||
|
{
|
||||||
|
public string? GetValidIp() => Ip == Global.None ? null : Ip;
|
||||||
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
namespace ServiceLib.Models.Dto;
|
namespace ServiceLib.Models.Dto;
|
||||||
|
|
||||||
public class CheckUpdateModel : ReactiveObject
|
public partial class CheckUpdateModel : ReactiveObject
|
||||||
{
|
{
|
||||||
public bool? IsSelected { get; set; }
|
public bool? IsSelected { get; set; }
|
||||||
public ECoreType? CoreType { get; set; }
|
public ECoreType? CoreType { get; set; }
|
||||||
[Reactive] public string? Remarks { get; set; }
|
[Reactive] public partial string? Remarks { get; set; }
|
||||||
public string? FileName { get; set; }
|
public string? FileName { get; set; }
|
||||||
public bool? IsFinished { get; set; }
|
public bool? IsFinished { get; set; }
|
||||||
public bool IsGeoFile { get; set; }
|
public bool IsGeoFile { get; set; }
|
||||||
|
|||||||
@@ -13,4 +13,5 @@ public class ClashConnectionModel
|
|||||||
public double Time { get; set; }
|
public double Time { get; set; }
|
||||||
public string? Elapsed { get; set; }
|
public string? Elapsed { get; set; }
|
||||||
public string? Chain { get; set; }
|
public string? Chain { get; set; }
|
||||||
|
public string? ProcessPath { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +1,37 @@
|
|||||||
namespace ServiceLib.Models.Dto;
|
namespace ServiceLib.Models.Dto;
|
||||||
|
|
||||||
public class ClashConnections
|
public record ClashConnections
|
||||||
{
|
{
|
||||||
public ulong downloadTotal { get; set; }
|
public ulong downloadTotal { get; init; }
|
||||||
public ulong uploadTotal { get; set; }
|
public ulong uploadTotal { get; init; }
|
||||||
public List<ConnectionItem>? connections { get; set; }
|
public List<ConnectionItem>? connections { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ConnectionItem
|
public record ConnectionItem
|
||||||
{
|
{
|
||||||
public string? id { get; set; }
|
public string? id { get; init; }
|
||||||
public MetadataItem? metadata { get; set; }
|
public MetadataItem? metadata { get; init; }
|
||||||
public ulong upload { get; set; }
|
public ulong upload { get; init; }
|
||||||
public ulong download { get; set; }
|
public ulong download { get; init; }
|
||||||
public DateTime start { get; set; }
|
public DateTime start { get; init; }
|
||||||
public List<string>? chains { get; set; }
|
public List<string>? chains { get; init; }
|
||||||
public string? rule { get; set; }
|
public string? rule { get; init; }
|
||||||
public string? rulePayload { get; set; }
|
public string? rulePayload { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class MetadataItem
|
public record MetadataItem
|
||||||
{
|
{
|
||||||
public string? network { get; set; }
|
public string? network { get; init; }
|
||||||
public string? type { get; set; }
|
public string? type { get; init; }
|
||||||
public string? sourceIP { get; set; }
|
public string? sourceIP { get; init; }
|
||||||
public string? destinationIP { get; set; }
|
public string? destinationIP { get; init; }
|
||||||
public string? sourcePort { get; set; }
|
public string? sourcePort { get; init; }
|
||||||
public string? destinationPort { get; set; }
|
public string? destinationPort { get; init; }
|
||||||
public string? host { get; set; }
|
public string? host { get; init; }
|
||||||
public string? nsMode { get; set; }
|
public string? nsMode { get; init; }
|
||||||
public object? uid { get; set; }
|
public object? uid { get; init; }
|
||||||
public string? process { get; set; }
|
public string? process { get; init; }
|
||||||
public string? processPath { get; set; }
|
public string? processPath { get; init; }
|
||||||
public string? remoteDestination { get; set; }
|
public string? remoteDestination { get; init; }
|
||||||
|
public string? sniffHost { get; init; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
namespace ServiceLib.Models.Dto;
|
||||||
|
|
||||||
|
public record ClashItem
|
||||||
|
{
|
||||||
|
public Dictionary<string, ClashProxy> Proxies { get; init; } = [];
|
||||||
|
public Dictionary<string, string> ProviderIndexMap { get; init; } = [];
|
||||||
|
|
||||||
|
public bool IsEmpty() => Proxies.Count == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ClashProxy
|
||||||
|
{
|
||||||
|
public List<string>? all { get; init; }
|
||||||
|
public List<HistoryItem>? history { get; init; }
|
||||||
|
public string? name { get; init; }
|
||||||
|
public string? type { get; init; }
|
||||||
|
public bool udp { get; init; }
|
||||||
|
public string? now { get; init; }
|
||||||
|
public int delay { get; init; }
|
||||||
|
|
||||||
|
public record HistoryItem
|
||||||
|
{
|
||||||
|
public string? time { get; init; }
|
||||||
|
public int delay { get; init; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ClashProvider
|
||||||
|
{
|
||||||
|
public string? name { get; init; }
|
||||||
|
public List<ClashProxy>? proxies { get; init; }
|
||||||
|
public string? type { get; init; }
|
||||||
|
public string? vehicleType { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ClashProxies
|
||||||
|
{
|
||||||
|
public Dictionary<string, ClashProxy> proxies { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ClashProviders
|
||||||
|
{
|
||||||
|
public Dictionary<string, ClashProvider> providers { get; init; } = [];
|
||||||
|
}
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
using static ServiceLib.Models.Dto.ClashProxies;
|
|
||||||
|
|
||||||
namespace ServiceLib.Models.Dto;
|
|
||||||
|
|
||||||
public class ClashProviders
|
|
||||||
{
|
|
||||||
public Dictionary<string, ProvidersItem>? providers { get; set; }
|
|
||||||
|
|
||||||
public class ProvidersItem
|
|
||||||
{
|
|
||||||
public string? name { get; set; }
|
|
||||||
public List<ProxiesItem>? proxies { get; set; }
|
|
||||||
public string? type { get; set; }
|
|
||||||
public string? vehicleType { get; set; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
namespace ServiceLib.Models.Dto;
|
|
||||||
|
|
||||||
public class ClashProxies
|
|
||||||
{
|
|
||||||
public Dictionary<string, ProxiesItem>? proxies { get; set; }
|
|
||||||
|
|
||||||
public class ProxiesItem
|
|
||||||
{
|
|
||||||
public List<string>? all { get; set; }
|
|
||||||
public List<HistoryItem>? history { get; set; }
|
|
||||||
public string? name { get; set; }
|
|
||||||
public string? type { get; set; }
|
|
||||||
public bool udp { get; set; }
|
|
||||||
public string? now { get; set; }
|
|
||||||
public int delay { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public class HistoryItem
|
|
||||||
{
|
|
||||||
public string? time { get; set; }
|
|
||||||
public int delay { get; set; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +1,17 @@
|
|||||||
namespace ServiceLib.Models.Dto;
|
namespace ServiceLib.Models.Dto;
|
||||||
|
|
||||||
[Serializable]
|
[Serializable]
|
||||||
public class ClashProxyModel : ReactiveObject
|
public partial class ClashProxyModel : ReactiveObject
|
||||||
{
|
{
|
||||||
public string? Name { get; set; }
|
public string Name { get; set; }
|
||||||
|
|
||||||
public string? Type { get; set; }
|
public string Type { get; set; }
|
||||||
|
|
||||||
public string? Now { get; set; }
|
public string? Now { get; set; }
|
||||||
|
|
||||||
[Reactive] public int Delay { get; set; }
|
[Reactive] public partial int Delay { get; set; }
|
||||||
|
|
||||||
[Reactive] public string? DelayName { get; set; }
|
[Reactive] public partial string? DelayName { get; set; }
|
||||||
|
|
||||||
public bool IsActive { get; set; }
|
public bool IsActive { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
namespace ServiceLib.Models.Dto;
|
||||||
|
|
||||||
|
public record FileDownloadState
|
||||||
|
{
|
||||||
|
public required FileDownloadRequest Request { get; init; }
|
||||||
|
public long DownloadedBytes { get; init; } = 0;
|
||||||
|
public long TotalBytes { get; init; } = 0;
|
||||||
|
public double SpeedBytesPerSecond { get; init; } = 0;
|
||||||
|
public bool Completed { get; init; } = false;
|
||||||
|
|
||||||
|
public Exception? Error { get; init; }
|
||||||
|
public bool IsFailed => Error != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public record FileDownloadRequest
|
||||||
|
{
|
||||||
|
public required string FileUrl { get; init; }
|
||||||
|
public required string FilePath { get; init; }
|
||||||
|
public string? DisplayFileName { get; init; }
|
||||||
|
|
||||||
|
public string FileName => DisplayFileName ?? Path.GetFileName(FilePath);
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace ServiceLib.Models.Dto;
|
||||||
|
|
||||||
|
public sealed record LanguageOption(string Value, string Display);
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
namespace ServiceLib.Models.Dto;
|
namespace ServiceLib.Models.Dto;
|
||||||
|
|
||||||
[Serializable]
|
[Serializable]
|
||||||
public class ProfileItemModel : ReactiveObject
|
public partial class ProfileItemModel : ReactiveObject
|
||||||
{
|
{
|
||||||
public bool IsActive { get; set; }
|
public bool IsActive { get; set; }
|
||||||
public string IndexId { get; set; }
|
public string IndexId { get; set; }
|
||||||
@@ -16,30 +16,30 @@ public class ProfileItemModel : ReactiveObject
|
|||||||
public int Sort { get; set; }
|
public int Sort { get; set; }
|
||||||
|
|
||||||
[Reactive]
|
[Reactive]
|
||||||
public int Delay { get; set; }
|
public partial int Delay { get; set; }
|
||||||
|
|
||||||
public decimal Speed { get; set; }
|
public decimal Speed { get; set; }
|
||||||
|
|
||||||
[Reactive]
|
[Reactive]
|
||||||
public string DelayVal { get; set; }
|
public partial string DelayVal { get; set; }
|
||||||
|
|
||||||
[Reactive]
|
[Reactive]
|
||||||
public string SpeedVal { get; set; }
|
public partial string SpeedVal { get; set; }
|
||||||
|
|
||||||
[Reactive]
|
[Reactive]
|
||||||
public string IpInfo { get; set; }
|
public partial string IpInfo { get; set; }
|
||||||
|
|
||||||
[Reactive]
|
[Reactive]
|
||||||
public string TodayUp { get; set; }
|
public partial string TodayUp { get; set; }
|
||||||
|
|
||||||
[Reactive]
|
[Reactive]
|
||||||
public string TodayDown { get; set; }
|
public partial string TodayDown { get; set; }
|
||||||
|
|
||||||
[Reactive]
|
[Reactive]
|
||||||
public string TotalUp { get; set; }
|
public partial string TotalUp { get; set; }
|
||||||
|
|
||||||
[Reactive]
|
[Reactive]
|
||||||
public string TotalDown { get; set; }
|
public partial string TotalDown { get; set; }
|
||||||
|
|
||||||
public string GetSummary()
|
public string GetSummary()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
namespace ServiceLib.Models.Dto;
|
namespace ServiceLib.Models.Dto;
|
||||||
|
|
||||||
public class SemanticVersion
|
public class SemanticVersion : IEquatable<SemanticVersion>, IComparable
|
||||||
{
|
{
|
||||||
|
private readonly string? build;
|
||||||
private readonly int major;
|
private readonly int major;
|
||||||
private readonly int minor;
|
private readonly int minor;
|
||||||
private readonly int patch;
|
private readonly int patch;
|
||||||
private readonly string version;
|
private readonly string? prerelease;
|
||||||
|
private readonly string raw;
|
||||||
|
|
||||||
public SemanticVersion(int major, int minor, int patch)
|
public SemanticVersion(int major, int minor, int patch)
|
||||||
{
|
{
|
||||||
this.major = major;
|
this.major = major;
|
||||||
this.minor = minor;
|
this.minor = minor;
|
||||||
this.patch = patch;
|
this.patch = patch;
|
||||||
version = $"{major}.{minor}.{patch}";
|
raw = $"{major}.{minor}.{patch}";
|
||||||
}
|
}
|
||||||
|
|
||||||
public SemanticVersion(string? version)
|
public SemanticVersion(string? version)
|
||||||
@@ -24,26 +26,41 @@ public class SemanticVersion
|
|||||||
major = 0;
|
major = 0;
|
||||||
minor = 0;
|
minor = 0;
|
||||||
patch = 0;
|
patch = 0;
|
||||||
|
raw = $"{major}.{minor}.{patch}";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.version = version.RemovePrefix('v');
|
raw = version;
|
||||||
|
|
||||||
var parts = this.version.Split('.');
|
var span = version.StartsWith("v", StringComparison.OrdinalIgnoreCase)
|
||||||
if (parts.Length == 2)
|
? version.AsSpan(1)
|
||||||
|
: version.AsSpan();
|
||||||
|
var plusIdx = span.IndexOf('+');
|
||||||
|
var metadataSpan = plusIdx >= 0 ? span[(plusIdx + 1)..] : [];
|
||||||
|
var leftOfPlus = plusIdx >= 0 ? span[..plusIdx] : span;
|
||||||
|
var dashIdx = leftOfPlus.IndexOf('-');
|
||||||
|
var versionSpan = dashIdx >= 0 ? leftOfPlus[..dashIdx] : leftOfPlus;
|
||||||
|
var preReleaseSpan = dashIdx >= 0 ? leftOfPlus[(dashIdx + 1)..] : [];
|
||||||
|
|
||||||
|
build = metadataSpan.Length > 0 ? metadataSpan.ToString() : null;
|
||||||
|
prerelease = preReleaseSpan.Length > 0 ? preReleaseSpan.ToString() : null;
|
||||||
|
|
||||||
|
var parts = versionSpan.ToString().Split('.');
|
||||||
|
switch (parts.Length)
|
||||||
{
|
{
|
||||||
major = int.Parse(parts.First());
|
case 2:
|
||||||
minor = int.Parse(parts.Last());
|
major = int.Parse(parts.First());
|
||||||
patch = 0;
|
minor = int.Parse(parts.Last());
|
||||||
}
|
patch = 0;
|
||||||
else if (parts.Length is 3 or 4)
|
break;
|
||||||
{
|
|
||||||
major = int.Parse(parts[0]);
|
case 3 or 4:
|
||||||
minor = int.Parse(parts[1]);
|
major = int.Parse(parts[0]);
|
||||||
patch = int.Parse(parts[2]);
|
minor = int.Parse(parts[1]);
|
||||||
}
|
patch = int.Parse(parts[2]);
|
||||||
else
|
break;
|
||||||
{
|
|
||||||
throw new ArgumentException("Invalid version string");
|
default:
|
||||||
|
throw new ArgumentException("Invalid version string");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
@@ -51,135 +68,186 @@ public class SemanticVersion
|
|||||||
major = 0;
|
major = 0;
|
||||||
minor = 0;
|
minor = 0;
|
||||||
patch = 0;
|
patch = 0;
|
||||||
|
raw = $"{major}.{minor}.{patch}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Equals(SemanticVersion? other)
|
||||||
|
{
|
||||||
|
if (other is null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (ReferenceEquals(this, other))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return major == other.major && minor == other.minor && patch == other.patch && prerelease == other.prerelease;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool TryParse(string? version, out SemanticVersion? result)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
result = new SemanticVersion(version);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
result = null;
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public override bool Equals(object? obj)
|
public override bool Equals(object? obj)
|
||||||
{
|
{
|
||||||
if (obj is SemanticVersion other)
|
return obj is SemanticVersion other && Equals(other);
|
||||||
{
|
|
||||||
return major == other.major && minor == other.minor && patch == other.patch;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override int GetHashCode()
|
public override int GetHashCode()
|
||||||
{
|
{
|
||||||
return major.GetHashCode() ^ minor.GetHashCode() ^ patch.GetHashCode();
|
return HashCode.Combine(major, minor, patch, prerelease);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Use ToVersionString(string? prefix) instead if possible.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>major.minor.patch</returns>
|
|
||||||
public override string ToString()
|
public override string ToString()
|
||||||
{
|
{
|
||||||
return version;
|
return raw;
|
||||||
}
|
}
|
||||||
|
|
||||||
public string ToVersionString(string? prefix = null)
|
public string ToStandardVersionString(string? prefix = null)
|
||||||
{
|
{
|
||||||
if (prefix == null)
|
var sb = new StringBuilder();
|
||||||
|
if (!string.IsNullOrEmpty(prefix))
|
||||||
{
|
{
|
||||||
return version;
|
sb.Append(prefix);
|
||||||
}
|
}
|
||||||
else
|
sb.Append($"{major}.{minor}.{patch}");
|
||||||
|
if (!string.IsNullOrEmpty(prerelease))
|
||||||
{
|
{
|
||||||
return $"{prefix}{version}";
|
sb.Append($"-{prerelease}");
|
||||||
}
|
}
|
||||||
|
if (!string.IsNullOrEmpty(build))
|
||||||
|
{
|
||||||
|
sb.Append($"+{build}");
|
||||||
|
}
|
||||||
|
return sb.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static bool operator ==(SemanticVersion v1, SemanticVersion v2)
|
public static bool operator <(SemanticVersion left, SemanticVersion right)
|
||||||
{ return v1.Equals(v2); }
|
{
|
||||||
|
return left.CompareTo(right) < 0;
|
||||||
|
}
|
||||||
|
|
||||||
public static bool operator !=(SemanticVersion v1, SemanticVersion v2)
|
public static bool operator >(SemanticVersion left, SemanticVersion right)
|
||||||
{ return !v1.Equals(v2); }
|
{
|
||||||
|
return left.CompareTo(right) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
public static bool operator >=(SemanticVersion v1, SemanticVersion v2)
|
public static bool operator <=(SemanticVersion left, SemanticVersion right)
|
||||||
{ return v1.GreaterEquals(v2); }
|
{
|
||||||
|
return left.CompareTo(right) <= 0;
|
||||||
|
}
|
||||||
|
|
||||||
public static bool operator <=(SemanticVersion v1, SemanticVersion v2)
|
public static bool operator >=(SemanticVersion left, SemanticVersion right)
|
||||||
{ return v1.LessEquals(v2); }
|
{
|
||||||
|
return left.CompareTo(right) >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
#region Private
|
#region Private
|
||||||
|
|
||||||
private bool GreaterEquals(SemanticVersion other)
|
public int CompareTo(SemanticVersion other)
|
||||||
{
|
{
|
||||||
if (major < other.major)
|
if (major != other.major)
|
||||||
{
|
{
|
||||||
return false;
|
return major.CompareTo(other.major);
|
||||||
}
|
}
|
||||||
else if (major > other.major)
|
if (minor != other.minor)
|
||||||
{
|
{
|
||||||
return true;
|
return minor.CompareTo(other.minor);
|
||||||
}
|
}
|
||||||
else
|
if (patch != other.patch)
|
||||||
{
|
{
|
||||||
if (minor < other.minor)
|
return patch.CompareTo(other.patch);
|
||||||
|
}
|
||||||
|
return ComparePreRelease(prerelease, other.prerelease);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int CompareTo(object? obj)
|
||||||
|
{
|
||||||
|
if (obj is null)
|
||||||
|
{
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (obj is SemanticVersion other)
|
||||||
|
{
|
||||||
|
return CompareTo(other);
|
||||||
|
}
|
||||||
|
throw new ArgumentException("Object is not a SemanticVersion");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ComparePreRelease(string? left, string? right)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(left) && string.IsNullOrEmpty(right))
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (string.IsNullOrEmpty(left))
|
||||||
|
{
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (string.IsNullOrEmpty(right))
|
||||||
|
{
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
var leftSpan = left.AsSpan();
|
||||||
|
var rightSpan = right.AsSpan();
|
||||||
|
using var leftEnum = leftSpan.Split('.').GetEnumerator();
|
||||||
|
using var rightEnum = rightSpan.Split('.').GetEnumerator();
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var hasLeft = leftEnum.MoveNext();
|
||||||
|
var hasRight = rightEnum.MoveNext();
|
||||||
|
|
||||||
|
if (!hasLeft && !hasRight)
|
||||||
{
|
{
|
||||||
return false;
|
return 0;
|
||||||
}
|
}
|
||||||
else if (minor > other.minor)
|
if (!hasLeft)
|
||||||
{
|
{
|
||||||
return true;
|
return -1;
|
||||||
}
|
}
|
||||||
else
|
if (!hasRight)
|
||||||
{
|
{
|
||||||
if (patch < other.patch)
|
return 1;
|
||||||
{
|
}
|
||||||
return false;
|
|
||||||
}
|
var leftSegment = leftSpan[leftEnum.Current];
|
||||||
else if (patch > other.patch)
|
var rightSegment = rightSpan[rightEnum.Current];
|
||||||
{
|
|
||||||
return true;
|
var segCmp = CompareSegment(leftSegment, rightSegment);
|
||||||
}
|
if (segCmp != 0)
|
||||||
else
|
{
|
||||||
{
|
return segCmp;
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool LessEquals(SemanticVersion other)
|
private static int CompareSegment(ReadOnlySpan<char> left, ReadOnlySpan<char> right)
|
||||||
{
|
{
|
||||||
if (major < other.major)
|
var leftIsNum = ulong.TryParse(left, out var leftNum);
|
||||||
|
var rightIsNum = ulong.TryParse(right, out var rightNum);
|
||||||
|
|
||||||
|
return (leftIsNum, rightIsNum) switch
|
||||||
{
|
{
|
||||||
return true;
|
(true, true) => leftNum.CompareTo(rightNum),
|
||||||
}
|
|
||||||
else if (major > other.major)
|
(false, true) => 1,
|
||||||
{
|
(true, false) => -1,
|
||||||
return false;
|
|
||||||
}
|
(false, false) => left.SequenceCompareTo(right),
|
||||||
else
|
};
|
||||||
{
|
|
||||||
if (minor < other.minor)
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
else if (minor > other.minor)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
if (patch < other.patch)
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
else if (patch > other.patch)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion Private
|
#endregion Private
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ public class ProfileItem
|
|||||||
|
|
||||||
public bool IsValid()
|
public bool IsValid()
|
||||||
{
|
{
|
||||||
if (IsComplex())
|
if (IsComplex() || ConfigType == EConfigType.Outbound)
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ public record ProtocolExtraItem
|
|||||||
public string? WgInterfaceAddress { get; init; }
|
public string? WgInterfaceAddress { get; init; }
|
||||||
public string? WgReserved { get; init; }
|
public string? WgReserved { get; init; }
|
||||||
public int? WgMtu { get; init; }
|
public int? WgMtu { get; init; }
|
||||||
|
public string? WgDns { get; init; }
|
||||||
|
|
||||||
// hysteria2
|
// hysteria2
|
||||||
public string? SalamanderPass { get; init; }
|
public string? SalamanderPass { get; init; }
|
||||||
@@ -51,4 +52,7 @@ public record ProtocolExtraItem
|
|||||||
public string? SubChildItems { get; init; }
|
public string? SubChildItems { get; init; }
|
||||||
public string? Filter { get; init; }
|
public string? Filter { get; init; }
|
||||||
public EMultipleLoad? MultipleLoad { get; init; }
|
public EMultipleLoad? MultipleLoad { get; init; }
|
||||||
|
|
||||||
|
// custom outbound
|
||||||
|
public bool? IsSingboxEndpoint { get; init; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ public class SubItem
|
|||||||
|
|
||||||
public string UserAgent { get; set; } = string.Empty;
|
public string UserAgent { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string? RequestHeaders { get; set; }
|
||||||
|
|
||||||
public int Sort { get; set; }
|
public int Sort { get; set; }
|
||||||
|
|
||||||
public string? Filter { get; set; }
|
public string? Filter { get; set; }
|
||||||
@@ -33,4 +35,6 @@ public class SubItem
|
|||||||
public int? PreSocksPort { get; set; }
|
public int? PreSocksPort { get; set; }
|
||||||
|
|
||||||
public string? Memo { get; set; }
|
public string? Memo { get; set; }
|
||||||
|
|
||||||
|
public ECoreType? CustomCoreType { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+222
-15
@@ -178,7 +178,7 @@ namespace ServiceLib.Resx {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 查找类似 Failed to import custom configuration Configuration 的本地化字符串。
|
/// 查找类似 Failed to import configuration 的本地化字符串。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static string FailedImportedCustomServer {
|
public static string FailedImportedCustomServer {
|
||||||
get {
|
get {
|
||||||
@@ -268,7 +268,7 @@ namespace ServiceLib.Resx {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 查找类似 Please browse to import Configuration configuration 的本地化字符串。
|
/// 查找类似 Please browse to import configuration 的本地化字符串。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static string FillServerAddressCustom {
|
public static string FillServerAddressCustom {
|
||||||
get {
|
get {
|
||||||
@@ -438,6 +438,15 @@ namespace ServiceLib.Resx {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查找类似 Custom config core 的本地化字符串。
|
||||||
|
/// </summary>
|
||||||
|
public static string LvCustomCoreType {
|
||||||
|
get {
|
||||||
|
return ResourceManager.GetString("LvCustomCoreType", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 查找类似 Custom icon 的本地化字符串。
|
/// 查找类似 Custom icon 的本地化字符串。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -546,6 +555,15 @@ namespace ServiceLib.Resx {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查找类似 HTTP headers (JSON) 的本地化字符串。
|
||||||
|
/// </summary>
|
||||||
|
public static string LvRequestHeaders {
|
||||||
|
get {
|
||||||
|
return ResourceManager.GetString("LvRequestHeaders", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 查找类似 Type 的本地化字符串。
|
/// 查找类似 Type 的本地化字符串。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -735,6 +753,15 @@ namespace ServiceLib.Resx {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查找类似 Add a custom outbound 的本地化字符串。
|
||||||
|
/// </summary>
|
||||||
|
public static string menuAddCustomOutboundServer {
|
||||||
|
get {
|
||||||
|
return ResourceManager.GetString("menuAddCustomOutboundServer", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 查找类似 Add a custom configuration 的本地化字符串。
|
/// 查找类似 Add a custom configuration 的本地化字符串。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -969,6 +996,15 @@ namespace ServiceLib.Resx {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查找类似 Latency Test 的本地化字符串。
|
||||||
|
/// </summary>
|
||||||
|
public static string menuDelaytest {
|
||||||
|
get {
|
||||||
|
return ResourceManager.GetString("menuDelaytest", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 查找类似 DNS Settings 的本地化字符串。
|
/// 查找类似 DNS Settings 的本地化字符串。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -1383,15 +1419,6 @@ namespace ServiceLib.Resx {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 查找类似 Latency Test 的本地化字符串。
|
|
||||||
/// </summary>
|
|
||||||
public static string menuProxiesDelaytest {
|
|
||||||
get {
|
|
||||||
return ResourceManager.GetString("menuProxiesDelaytest", resourceCulture);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 查找类似 Part Node Latency Test 的本地化字符串。
|
/// 查找类似 Part Node Latency Test 的本地化字符串。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -1933,7 +1960,7 @@ namespace ServiceLib.Resx {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 查找类似 Warning: Xray will disable allowInsecure (skip certificate verification) in August 2026. Please switch to pinnedPeerCertSha256 (fixed certificate fingerprint) as soon as possible. allowInsecure will not be usable after its expiration. 的本地化字符串。
|
/// 查找类似 The current node uses an unencrypted connection, meaning your communications could be directly monitored by network intermediaries controlled by authoritarian governments. For security reasons, nodes of this type cannot connect via Xray-core versions 26.2.6 or higher. If this is a self-built node, please enable TLS or other secure encryption, or pin the certificate using pinSHA256. If this is an airport/provider node, please contact your service provider for a technical upgrade. If the provider refuses to c [字符串的其余部分被截断]"; 的本地化字符串。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static string MsgAllowInsecureDeprecated {
|
public static string MsgAllowInsecureDeprecated {
|
||||||
get {
|
get {
|
||||||
@@ -1977,6 +2004,15 @@ namespace ServiceLib.Resx {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查找类似 Custom outbound {0} file not found: {1} 的本地化字符串。
|
||||||
|
/// </summary>
|
||||||
|
public static string MsgCustomOutboundFileNotFound {
|
||||||
|
get {
|
||||||
|
return ResourceManager.GetString("MsgCustomOutboundFileNotFound", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 查找类似 Downloaded GeoFile: {0} successfully 的本地化字符串。
|
/// 查找类似 Downloaded GeoFile: {0} successfully 的本地化字符串。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -2607,6 +2643,24 @@ namespace ServiceLib.Resx {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查找类似 Invalid HTTP headers. Use a JSON object with unique header names and string values. Header names and values must not contain line breaks. 的本地化字符串。
|
||||||
|
/// </summary>
|
||||||
|
public static string SubRequestHeadersInvalid {
|
||||||
|
get {
|
||||||
|
return ResourceManager.GetString("SubRequestHeadersInvalid", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查找类似 Optional. Example: {"X-hwid": "my_device"}. Values override default headers for all URLs in this group, including the subscription conversion service. 的本地化字符串。
|
||||||
|
/// </summary>
|
||||||
|
public static string SubRequestHeadersTips {
|
||||||
|
get {
|
||||||
|
return ResourceManager.GetString("SubRequestHeadersTips", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 查找类似 For group please leave blank here 的本地化字符串。
|
/// 查找类似 For group please leave blank here 的本地化字符串。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -2626,7 +2680,7 @@ namespace ServiceLib.Resx {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 查找类似 Custom configuration Configuration imported successfully 的本地化字符串。
|
/// 查找类似 Custom configuration imported successfully 的本地化字符串。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static string SuccessfullyImportedCustomServer {
|
public static string SuccessfullyImportedCustomServer {
|
||||||
get {
|
get {
|
||||||
@@ -2742,6 +2796,24 @@ namespace ServiceLib.Resx {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查找类似 Block AAAA Queries 的本地化字符串。
|
||||||
|
/// </summary>
|
||||||
|
public static string TbBlockAAAAQueries {
|
||||||
|
get {
|
||||||
|
return ResourceManager.GetString("TbBlockAAAAQueries", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查找类似 Block IPv6 queries when enabled 的本地化字符串。
|
||||||
|
/// </summary>
|
||||||
|
public static string TbBlockAAAAQueriesTips {
|
||||||
|
get {
|
||||||
|
return ResourceManager.GetString("TbBlockAAAAQueriesTips", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 查找类似 Block SVCB and HTTPS Queries 的本地化字符串。
|
/// 查找类似 Block SVCB and HTTPS Queries 的本地化字符串。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -2752,7 +2824,7 @@ namespace ServiceLib.Resx {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 查找类似 Block ECH and HTTP/3 availability checks when enabled 的本地化字符串。
|
/// 查找类似 Block ECH and HTTP/3 availability checks when enabled. Always enabled in Xray 的本地化字符串。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static string TbBlockSVCBHTTPSQueriesTips {
|
public static string TbBlockSVCBHTTPSQueriesTips {
|
||||||
get {
|
get {
|
||||||
@@ -2925,6 +2997,15 @@ namespace ServiceLib.Resx {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查找类似 Only single outbound/endpoint supported for xray/sing-box 的本地化字符串。
|
||||||
|
/// </summary>
|
||||||
|
public static string TbCustomOutboundTip {
|
||||||
|
get {
|
||||||
|
return ResourceManager.GetString("TbCustomOutboundTip", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 查找类似 Direct Target Resolution Strategy 的本地化字符串。
|
/// 查找类似 Direct Target Resolution Strategy 的本地化字符串。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -2961,6 +3042,15 @@ namespace ServiceLib.Resx {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查找类似 DNS 的本地化字符串。
|
||||||
|
/// </summary>
|
||||||
|
public static string TbDNS {
|
||||||
|
get {
|
||||||
|
return ResourceManager.GetString("TbDNS", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 查找类似 DNS Hosts: ("domain1 ip1 ip2" per line) 的本地化字符串。
|
/// 查找类似 DNS Hosts: ("domain1 ip1 ip2" per line) 的本地化字符串。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -3060,6 +3150,24 @@ namespace ServiceLib.Resx {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查找类似 Enable Happy Eyeballs 的本地化字符串。
|
||||||
|
/// </summary>
|
||||||
|
public static string TbEnableHappyEyeballs {
|
||||||
|
get {
|
||||||
|
return ResourceManager.GetString("TbEnableHappyEyeballs", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查找类似 Requires the UseIP Strategy. When enabled, it attempts IPv4 and IPv6 connections simultaneously and automatically selects the faster available path. 的本地化字符串。
|
||||||
|
/// </summary>
|
||||||
|
public static string TbEnableHappyEyeballsTip {
|
||||||
|
get {
|
||||||
|
return ResourceManager.GetString("TbEnableHappyEyeballsTip", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 查找类似 DNS via Bridge 的本地化字符串。
|
/// 查找类似 DNS via Bridge 的本地化字符串。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -3088,7 +3196,7 @@ namespace ServiceLib.Resx {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 查找类似 Applies globally by default, with built-in FakeIP filtering (sing-box only). 的本地化字符串。
|
/// 查找类似 Applies globally by default, and built-in FakeIP filtering is only built into sing-box. 的本地化字符串。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static string TbFakeIPTips {
|
public static string TbFakeIPTips {
|
||||||
get {
|
get {
|
||||||
@@ -3330,6 +3438,24 @@ namespace ServiceLib.Resx {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查找类似 Ipv4 Address 的本地化字符串。
|
||||||
|
/// </summary>
|
||||||
|
public static string TbIpv4Address {
|
||||||
|
get {
|
||||||
|
return ResourceManager.GetString("TbIpv4Address", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查找类似 Ipv6 Address 的本地化字符串。
|
||||||
|
/// </summary>
|
||||||
|
public static string TbIpv6Address {
|
||||||
|
get {
|
||||||
|
return ResourceManager.GetString("TbIpv6Address", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 查找类似 Most Stable 的本地化字符串。
|
/// 查找类似 Most Stable 的本地化字符串。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -3528,6 +3654,24 @@ namespace ServiceLib.Resx {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查找类似 Proxy Dial Resolution Strategy 的本地化字符串。
|
||||||
|
/// </summary>
|
||||||
|
public static string TbProxyDialResolveStrategy {
|
||||||
|
get {
|
||||||
|
return ResourceManager.GetString("TbProxyDialResolveStrategy", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查找类似 Not recommended; may cause routing loops. 的本地化字符串。
|
||||||
|
/// </summary>
|
||||||
|
public static string TbProxyDialResolveStrategyTip {
|
||||||
|
get {
|
||||||
|
return ResourceManager.GetString("TbProxyDialResolveStrategyTip", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 查找类似 Public Key 的本地化字符串。
|
/// 查找类似 Public Key 的本地化字符串。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -4158,6 +4302,15 @@ namespace ServiceLib.Resx {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查找类似 Update via proxy 的本地化字符串。
|
||||||
|
/// </summary>
|
||||||
|
public static string TbSettingsEnableUpdateViaProxy {
|
||||||
|
get {
|
||||||
|
return ResourceManager.GetString("TbSettingsEnableUpdateViaProxy", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 查找类似 Exception 的本地化字符串。
|
/// 查找类似 Exception 的本地化字符串。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -4419,6 +4572,42 @@ namespace ServiceLib.Resx {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查找类似 Xray Mux setting 的本地化字符串。
|
||||||
|
/// </summary>
|
||||||
|
public static string TbSettingsMux4Ray {
|
||||||
|
get {
|
||||||
|
return ResourceManager.GetString("TbSettingsMux4Ray", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查找类似 Xray Mux concurrency 的本地化字符串。
|
||||||
|
/// </summary>
|
||||||
|
public static string TbSettingsMux4RayConcurrency {
|
||||||
|
get {
|
||||||
|
return ResourceManager.GetString("TbSettingsMux4RayConcurrency", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查找类似 Xray Mux XUDP concurrency 的本地化字符串。
|
||||||
|
/// </summary>
|
||||||
|
public static string TbSettingsMux4RayXudpConcurrency {
|
||||||
|
get {
|
||||||
|
return ResourceManager.GetString("TbSettingsMux4RayXudpConcurrency", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查找类似 Xray Mux XUDP proxy UDP443 的本地化字符串。
|
||||||
|
/// </summary>
|
||||||
|
public static string TbSettingsMux4RayXudpProxyUDP443 {
|
||||||
|
get {
|
||||||
|
return ResourceManager.GetString("TbSettingsMux4RayXudpProxyUDP443", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 查找类似 sing-box Mux Protocol 的本地化字符串。
|
/// 查找类似 sing-box Mux Protocol 的本地化字符串。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -4833,6 +5022,15 @@ namespace ServiceLib.Resx {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查找类似 Process 的本地化字符串。
|
||||||
|
/// </summary>
|
||||||
|
public static string TbSortingProcess {
|
||||||
|
get {
|
||||||
|
return ResourceManager.GetString("TbSortingProcess", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 查找类似 Time 的本地化字符串。
|
/// 查找类似 Time 的本地化字符串。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -4941,6 +5139,15 @@ namespace ServiceLib.Resx {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查找类似 Xray Only 的本地化字符串。
|
||||||
|
/// </summary>
|
||||||
|
public static string TbXrayOnly {
|
||||||
|
get {
|
||||||
|
return ResourceManager.GetString("TbXrayOnly", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 查找类似 The delay: {0} ms, {1} 的本地化字符串。
|
/// 查找类似 The delay: {0} ms, {1} 的本地化字符串。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -693,6 +693,9 @@
|
|||||||
<data name="TbSettingsEnableCheckPreReleaseUpdate" xml:space="preserve">
|
<data name="TbSettingsEnableCheckPreReleaseUpdate" xml:space="preserve">
|
||||||
<value>به روز رسانی های پیش از انتشار را بررسی شود</value>
|
<value>به روز رسانی های پیش از انتشار را بررسی شود</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="TbSettingsEnableUpdateViaProxy" xml:space="preserve">
|
||||||
|
<value>بهروزرسانی از طریق پروکسی</value>
|
||||||
|
</data>
|
||||||
<data name="TbSettingsException" xml:space="preserve">
|
<data name="TbSettingsException" xml:space="preserve">
|
||||||
<value>استثنا</value>
|
<value>استثنا</value>
|
||||||
</data>
|
</data>
|
||||||
@@ -1215,7 +1218,7 @@
|
|||||||
<data name="menuModeRule" xml:space="preserve">
|
<data name="menuModeRule" xml:space="preserve">
|
||||||
<value>قانون</value>
|
<value>قانون</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="menuProxiesDelaytest" xml:space="preserve">
|
<data name="menuDelaytest" xml:space="preserve">
|
||||||
<value>تست تأخیر</value>
|
<value>تست تأخیر</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="menuProxiesDelaytestPart" xml:space="preserve">
|
<data name="menuProxiesDelaytestPart" xml:space="preserve">
|
||||||
@@ -1456,7 +1459,7 @@
|
|||||||
<value>Custom DNS Enabled, This Page's Settings Invalid</value>
|
<value>Custom DNS Enabled, This Page's Settings Invalid</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="TbBlockSVCBHTTPSQueriesTips" xml:space="preserve">
|
<data name="TbBlockSVCBHTTPSQueriesTips" xml:space="preserve">
|
||||||
<value>Block ECH and HTTP/3 availability checks when enabled</value>
|
<value>Block ECH and HTTP/3 availability checks when enabled. Always enabled in Xray</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="FillCorrectConfigTemplateText" xml:space="preserve">
|
<data name="FillCorrectConfigTemplateText" xml:space="preserve">
|
||||||
<value>Please fill in the correct config template</value>
|
<value>Please fill in the correct config template</value>
|
||||||
@@ -1495,7 +1498,7 @@
|
|||||||
<value>Select Profile</value>
|
<value>Select Profile</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="TbFakeIPTips" xml:space="preserve">
|
<data name="TbFakeIPTips" xml:space="preserve">
|
||||||
<value>Applies globally by default, with built-in FakeIP filtering (sing-box only).</value>
|
<value>Applies globally by default, and built-in FakeIP filtering is only built into sing-box.</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="PleaseAddAtLeastOneServer" xml:space="preserve">
|
<data name="PleaseAddAtLeastOneServer" xml:space="preserve">
|
||||||
<value>Please Add At Least One Configuration</value>
|
<value>Please Add At Least One Configuration</value>
|
||||||
|
|||||||
@@ -1212,7 +1212,7 @@
|
|||||||
<data name="menuModeRule" xml:space="preserve">
|
<data name="menuModeRule" xml:space="preserve">
|
||||||
<value>Règle</value>
|
<value>Règle</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="menuProxiesDelaytest" xml:space="preserve">
|
<data name="menuDelaytest" xml:space="preserve">
|
||||||
<value>Test de latence</value>
|
<value>Test de latence</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="menuProxiesDelaytestPart" xml:space="preserve">
|
<data name="menuProxiesDelaytestPart" xml:space="preserve">
|
||||||
@@ -1453,7 +1453,7 @@
|
|||||||
<value>DNS personnalisé activé ; la configuration de cette page sera ignorée</value>
|
<value>DNS personnalisé activé ; la configuration de cette page sera ignorée</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="TbBlockSVCBHTTPSQueriesTips" xml:space="preserve">
|
<data name="TbBlockSVCBHTTPSQueriesTips" xml:space="preserve">
|
||||||
<value>Une fois activé, bloque les requêtes ECH et de disponibilité HTTP/3</value>
|
<value>Block ECH and HTTP/3 availability checks when enabled. Always enabled in Xray</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="FillCorrectConfigTemplateText" xml:space="preserve">
|
<data name="FillCorrectConfigTemplateText" xml:space="preserve">
|
||||||
<value>Veuillez saisir un modèle de configuration valide</value>
|
<value>Veuillez saisir un modèle de configuration valide</value>
|
||||||
@@ -1492,7 +1492,7 @@
|
|||||||
<value>Choisir une config.</value>
|
<value>Choisir une config.</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="TbFakeIPTips" xml:space="preserve">
|
<data name="TbFakeIPTips" xml:space="preserve">
|
||||||
<value>Actif globalement par défaut, avec filtre FakeIP intégré ; ne fonctionne que dans sing-box</value>
|
<value>Applies globally by default, and built-in FakeIP filtering is only built into sing-box.</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="PleaseAddAtLeastOneServer" xml:space="preserve">
|
<data name="PleaseAddAtLeastOneServer" xml:space="preserve">
|
||||||
<value>Veuillez ajouter au moins une configuration</value>
|
<value>Veuillez ajouter au moins une configuration</value>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user