Compare commits

..

33 Commits

Author SHA1 Message Date
2dust 521230c40d up 7.24.9 2026-08-29 10:46:53 +08:00
Miheichev Aleksandr Sergeevich 426d5d5a21 ci: run the test job on pushes to master (#10054)
The Code Test workflow only has a pull_request trigger, so the suite
never runs against master itself: a direct push is untested, and a
merge can break tests even when the pull request's own check was
green, because checks run on the PR head rather than on the merged
result. Add a push trigger for master with the same paths filter.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 09:42:40 +08:00
2dust 3f14c86a04 Trigger routing migration when rules exist 2026-08-28 15:00:19 +08:00
dependabot[bot] 70b8ecd3c8 Bump TUnit from 1.65.63 to 1.65.68 (#10052)
---
updated-dependencies:
- dependency-name: TUnit
  dependency-version: 1.65.68
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-28 14:38:57 +08:00
DHR60 0d452328a2 Add wireguard remote dns support (#10036) 2026-08-28 14:38:43 +08:00
Miheichev Aleksandr Sergeevich 2a65e5ac80 fix(fmt): stop share-URI query values from being decoded twice or dropped (#10027)
`Utils.ParseQueryString` already unescapes every value, and
`BaseFmt.GetQueryDecoded` unescaped it again. A value that still held a
valid percent sequence after the first pass decayed on the second: an
obfuscation password of `ob%41fs` is exported as `ob%2541fs` and imported
back as `obAfs`. Only well-formed sequences are affected, which is why the
damage is silent - `100%` and `66%ff` survive untouched.

The same function also split each pair on every `=`, and skipped the pair
unless exactly two halves came out. RFC 3986 lists `=` among the
sub-delimiters a query value may carry, so only the first one separates the
key from the value, and `HttpUtility.ParseQueryString` reads a query string
the same way. Splitting on all of them discarded a syntactically valid
pair: `?ech=AAj+DQAEAAAAAA==` was lost entirely, and so was a `plugin`
value in the non-canonical SIP002 spelling, since those are `;` separated
`key=value` lists.

v2rayN percent-encodes both on export, so its own links were never
affected; what changes is that the parser now follows the grammar instead
of discarding a pair it cannot split in two.

Splitting on the first `=` only, and reading the value the parser already
decoded, fixes both. `ParseQueryString` keeps decoding because
`ConfigHandler` reads its result directly.

`GetQueryDecoded` and `GetQueryValue` are now equivalent; they are left
separate to keep this change small, and can be collapsed if you prefer.
2026-08-28 14:38:02 +08:00
Miheichev Aleksandr Sergeevich f332caf507 fix(hysteria2): default the port to 443 when the share URI omits it (#10026)
The Hysteria2 URI scheme makes the port optional: "The hostname and
optional port of the server. If the port is omitted, it defaults to 443."
`Hysteria2Fmt.Resolve` assigned `url.Port` straight through, and
`System.Uri` answers -1 for an unregistered scheme that carries no port,
so `hysteria2://password@hy2.example/` imported as a profile with
`Port = -1`. `ProfileItem.IsValid` rejects any port outside 1..65535, so
such a link produced a profile that could never be used, and nothing said
why.

-1 is the only value that means "the port was omitted"; a ':' with no
digits after it maps to -1 as well. An explicit ":0" parses as 0 and
keeps the fate it has today - rejected by `IsValid` - rather than being
redirected to a server the link never named.

`ResolveRealm` takes its port from `HyRealm.RendezvousPort` instead of
the URI, so it is unaffected.

The added tests cover both spellings of the scheme, with and without a
trailing slash, a bare ':', and the resulting profile's validity. Two of
them are controls: an explicit port is still preserved, and an explicit
":0" still does not turn into 443.
2026-08-28 14:33:05 +08:00
Miheichev Aleksandr Sergeevich 4b412e246f test: cover the share-URI round trip for the remaining protocols (#10017)
`FmtHandlerTests` round-tripped VMess, VLESS, Shadowsocks and SOCKS.
`FmtHandler.GetShareUri` dispatches ten protocols, so Trojan, Hysteria2,
TUIC, Anytls, WireGuard and Naive were exported and re-imported untested,
and `WireguardFmt` was covered in the `Resolve` direction only.

Each new test exports a profile, imports the result and asserts the fields
that protocol carries in its URI: the flow for Trojan, the uuid/password
pair and the congestion control for TUIC, the obfuscation password and the
port range for Hysteria2, the peer keys, reserved bytes, interface address
and MTU for WireGuard, and the credentials plus the insecure concurrency
for Naive.

`ShareUriSuite_ShouldCoverAndRoundTripEveryExportableProtocol` compares the
profile-factory map against `Global.ProtocolShares` and round-trips every
entry, so a newly exportable protocol cannot be added without a case here.

Three things a round trip alone cannot prove are asserted on the wire form
instead. The allow-insecure flag is spelled per protocol since #9888 -
`allowInsecure` and `insecure` for Trojan, `allow_insecure` for TUIC,
`insecure` for Anytls and Hysteria2 - so an exporter and an importer that
agreed on the wrong name would otherwise round-trip cleanly. The WireGuard
test pins the percent-encoding of the base64 keys and the brackets around
the IPv6 literal. Hysteria2 keeps `CertSha` unset on purpose: the importer
turns `AllowInsecure` on by itself when a `pinSHA256` is present, which
would mask an exporter that stopped emitting `insecure=1`.

Fixtures are deterministic and no longer plain ASCII: a fixed uuid for
TUIC, real 32-byte base64 keys and an IPv6 address for WireGuard, and
reserved and non-Latin characters in passwords and remarks.

`ExportThenImport` derives the expected scheme, because `NaiveFmt` emits
`naive+https://` or `naive+quic://` and never the `naive://` prefix that
`Global.ProtocolShares` records for that type - that entry is only read
when importing.
2026-08-28 14:27:50 +08:00
Miheichev Aleksandr Sergeevich 09ac4d181a ci: run the test job on its own inputs and trim its checkout (#10016)
Follow-ups to #10007, none of which change what the test command does.

The `paths:` filter listed individual source folders rather than the
projects the suite builds, so a change anywhere else in that build
produced no check at all. Of the last 40 merged pull requests, 31 touch
the test build and 15 of those ran no tests. #9976 and #9932 edit
`ServiceLib/Common/`, which every test depends on, and neither shows a
single check. `Directory.Build.props` is the sharpest case: it sets
`TargetFramework` and the Release options for every project, so an edit
there can break the test build without producing a workflow run at all.

The filter now follows the project graph instead. `ServiceLib.Tests`
references `ServiceLib`, which references `ServiceLib.UdpTest`, and
`Directory.Build.*`, `Directory.Packages.props` and `global.json`
configure that build. It is shorter than the list it replaces, needs no
edit when a test is added or a class moves, and still skips UI and
documentation work: of those same 40 pull requests, 9 stay filtered out.

`global.json` belongs there for a different reason: it does not affect a
single line of the code under test, but it decides whether the tests run
at all, so a bad edit there silently reproduces the failure #10007 fixed.

The Checkout step needs neither `submodules: 'recursive'` nor
`fetch-depth: '0'`. `GlobalHotKeys` is pulled in by `v2rayN.Desktop`
only, and nothing in this job reads git history.

`global.json` also gained the final newline `.editorconfig` asks for with
`insert_final_newline = true` under `[*]`.
2026-08-28 14:27:11 +08:00
Miheichev Aleksandr Sergeevich c575527725 i18n(ru): translate newly added UI strings (#10015)
* i18n(ru): translate newly added UI strings

Translate the 3 strings missing from ResUI.ru.resx after the DNS
"Block AAAA Queries" toggle and the Xray-only certificate pinning
hint were introduced:

- TbXrayOnly, TbBlockAAAAQueries, TbBlockAAAAQueriesTips

Translated from the zh-Hans source and cross-checked against the
English resource. Russian regains full key parity with ResUI.resx
(583/583), and key ordering mirrors the English resource file.
Xray and the AAAA record type stay untranslated, matching the
established glossary; the phrasing follows the neighbouring
TbBlockSVCBHTTPSQueries label and the existing "При включении" tip pattern.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* i18n(ru): translate TbBlockSVCBHTTPSQueriesTips left in English

The key existed in ResUI.ru.resx but its value was the untranslated
English text, so the DNS settings window mixed Russian and English
in the row right above the newly translated "Block AAAA Queries".

Translated from the zh-Hans source, which says "availability
queries" (可用性查询); the Russian follows that wording rather than
the English "checks". ECH, HTTP/3 and Xray stay untranslated,
matching the established glossary, and the tip keeps the
"При включении …" pattern used by the surrounding tips.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 14:26:02 +08:00
2dust 589ccc6e06 Bug fix
https://github.com/2dust/v2rayN/issues/10043
2026-08-28 14:24:12 +08:00
DHR60 0a2f3e4f22 Fix hy2 fmt (#10044) 2026-08-27 15:31:39 +08:00
dependabot[bot] 007adb6403 Bump TUnit from 1.65.38 to 1.65.63 (#10037)
---
updated-dependencies:
- dependency-name: TUnit
  dependency-version: 1.65.63
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-26 19:42:53 +08:00
DHR60 e50c0a9170 Fix (#10035)
* Fix

* Fix

* Use HashSet instead of List

* Use HashSet instead of List for sing-box
2026-08-26 19:41:52 +08:00
2dust af0eb9ed14 Code clean 2026-08-22 17:32:26 +08:00
DHR60 0436081436 Revert "fix(tun): automatically exclude proxy node IPs and resolved domains from TUN routing to prevent loops (#9974) (#10012)" (#10013)
This reverts commit 2be63d1655.
2026-08-22 15:02:24 +08:00
2dust 2a89a2e597 up 7.24.8 2026-08-22 15:01:25 +08:00
Mangoo 2be63d1655 fix(tun): automatically exclude proxy node IPs and resolved domains from TUN routing to prevent loops (#9974) (#10012) 2026-08-22 14:49:27 +08:00
DHR60 74bd28bbd0 Fix test (#10007) 2026-08-22 10:25:57 +08:00
DHR60 be501cbd2a Fix dependabot path (#10008) 2026-08-22 10:20:41 +08:00
Miheichev Aleksandr Sergeevich e20b4dbfab chore(deps): update CliWrap and TUnit, drop unused test pins (#10005)
* chore(deps): update CliWrap and TUnit

- CliWrap                  3.10.4      -> 3.10.5
- TUnit                    1.65.0      -> 1.65.38
- TUnit.Assertions.Should  1.65.0-beta -> 1.65.38-beta

TUnit.Assertions.Should is versioned in lockstep with TUnit and published with
a -beta suffix. Its 1.65.0-beta package depends on TUnit.Assertions 1.65.0-beta,
so it has to move together with TUnit to keep the whole TUnit stack resolved on
a single version (TUnit.Assertions 1.65.38 after this change).

* chore(deps): drop unused AwesomeAssertions and xunit.v3 pins

Since the migration to TUnit (c9e843aa) no project references AwesomeAssertions
or xunit.v3 any more, so these two PackageVersion entries no longer pin
anything: neither package appears in any project.assets.json, directly or
transitively.

Verified by restoring, building and publishing the whole solution with and
without the two entries: the resolved package graph (769 entries across all
projects) and the published output of every CI target - v2rayN win-x64,
v2rayN.Desktop win-x64 and linux-x64, AmazTool win-x64, all self-contained -
are byte-identical, and ServiceLib.Tests stays at 69/69.
2026-08-22 10:19:29 +08:00
DHR60 728cbe70f2 Add block AAAA query support (#10004) 2026-08-22 10:18:48 +08:00
DHR60 26a86f9bd2 Fix (#10002) 2026-08-22 10:17:09 +08:00
DHR60 c66c55b05e Fix dnd (#10003)
* Fix dnd

* Fix
2026-08-22 10:13:21 +08:00
DHR60 ebb4bd5daa Optimize file download (#9952)
* Optimize file download

* Optimize func call
2026-08-21 14:15:54 +08:00
dayepao 14e27f3d23 fix: preserve Mihomo Reality short-id as YAML string (#9964) 2026-08-20 10:47:24 +08:00
DHR60 2d514ff480 Fix (#9991) 2026-08-19 13:48:59 +08:00
DHR60 11bd52b6a5 Fix (#9987) 2026-08-19 09:56:38 +08:00
JieXu 3b3fe8def3 Update build.yml (#9986)
* Update build.yml

* Update build-windows-x86.yml
2026-08-19 09:56:21 +08:00
DHR60 c9e843aa3b TUnit (#9981) 2026-08-19 09:54:51 +08:00
DHR60 7be264c65c Log with caller (#9976) 2026-08-17 14:33:33 +08:00
Miheichev Aleksandr Sergeevich 5e6660aeee chore(deps): update xunit.v3 and NLog, and drop the VSTest packages xunit replaces (#9975)
* chore(deps): update xunit.v3 to 4.0.0 and drop the VSTest packages

xunit.v3 4.0.0 moves from Microsoft.Testing.Platform v1 to v2, and MTP v2 drops the VSTest bridge on the .NET 10 SDK, so anything routed through VSTest now fails before a single test runs.

Rather than bridging back to VSTest, the two VSTest-era packages are removed. The test project is already an executable carrying xunit's own in-process runner, so Microsoft.NET.Test.Sdk and xunit.runner.visualstudio have nothing left to contribute, and without them no opt-in file is needed anywhere.

No source or test changes are required: every 4.0.0 breaking change is in the extensibility and runner APIs, and the suite uses only [Fact], [Theory] and [InlineData].

* ci: run the tests directly and let versions float on their major

test.yml requested the 8.0.x SDK while every project targets net10.0, which an 8.0 SDK cannot build (NETSDK1045), and it invoked dotnet test, which needs the VSTest bridge that MTP v2 has dropped. It now runs the test executable, which needs no adapter and no test SDK.

All three setup-dotnet steps ask for 10.x with quality ga, so a new .NET 10 patch or feature band is picked up automatically while previews and release candidates stay out of builds. setup-dotnet and upload-artifact were the only actions pinned to an exact patch; they now track their major tag like the other seven.

* chore(deps): update NLog to 6.2.0

A minor release with no API change on the surface this project uses. Verified beyond compilation: Logging.Setup builds its FileTarget, and both SaveLog overloads write through it at runtime with the expected layout.
2026-08-17 14:33:18 +08:00
JieXu 5b27f4836f Update package-rhel-riscv.sh (#9971)
* Update package-rhel-riscv.sh

* Update package-rhel-loong.sh

* Update package-debian-riscv.sh

* Update package-debian-loong.sh
2026-08-16 10:08:08 +08:00
70 changed files with 1736 additions and 729 deletions
+1 -1
View File
@@ -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 -1
View File
@@ -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@v6.0.0 uses: actions/setup-dotnet@v6
with: with:
dotnet-version: 10.0.1xx dotnet-version: 10.0.1xx
+4 -4
View File
@@ -69,10 +69,10 @@ 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@v6.0.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
working-directory: ./v2rayN working-directory: ./v2rayN
@@ -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 }}
+32 -9
View File
@@ -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@v6.0.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
+5
View File
@@ -0,0 +1,5 @@
{
"test": {
"runner": "Microsoft.Testing.Platform"
}
}
+2 -2
View File
@@ -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.110" DOTNET_LOONGARCH_VERSION="10.0.111"
DOTNET_LOONGARCH_TAG="v10.0.110-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}"
+1 -1
View File
@@ -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.110" 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}"
+2 -2
View File
@@ -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.110" DOTNET_LOONGARCH_VERSION="10.0.111"
DOTNET_LOONGARCH_TAG="v10.0.110-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}"
+1 -1
View File
@@ -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.110" 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}"
+1 -1
View File
@@ -1,7 +1,7 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<Version>7.24.7</Version> <Version>7.24.9</Version>
</PropertyGroup> </PropertyGroup>
<PropertyGroup> <PropertyGroup>
+5 -7
View File
@@ -9,13 +9,11 @@
<PackageVersion Include="Avalonia.Controls.DataGrid" Version="12.1.2" /> <PackageVersion Include="Avalonia.Controls.DataGrid" Version="12.1.2" />
<PackageVersion Include="Avalonia.Desktop" Version="12.1.1" /> <PackageVersion Include="Avalonia.Desktop" Version="12.1.1" />
<PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3" /> <PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3" />
<PackageVersion Include="AwesomeAssertions" Version="9.5.0" />
<PackageVersion Include="DialogHost.Avalonia" Version="0.12.3" /> <PackageVersion Include="DialogHost.Avalonia" Version="0.12.3" />
<PackageVersion Include="IPNetwork2" Version="4.3.0" /> <PackageVersion Include="IPNetwork2" Version="4.3.0" />
<PackageVersion Include="ReactiveUI.Avalonia" Version="12.1.1" /> <PackageVersion Include="ReactiveUI.Avalonia" Version="12.1.1" />
<PackageVersion Include="CliWrap" Version="3.10.4" /> <PackageVersion Include="CliWrap" Version="3.10.5" />
<PackageVersion Include="Downloader" Version="5.9.5" /> <PackageVersion Include="Downloader" Version="5.9.5" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
<PackageVersion Include="H.NotifyIcon.Wpf" Version="2.4.1" /> <PackageVersion Include="H.NotifyIcon.Wpf" Version="2.4.1" />
<PackageVersion Include="MaterialDesignThemes" Version="5.3.2" /> <PackageVersion Include="MaterialDesignThemes" Version="5.3.2" />
<PackageVersion Include="QRCoder" Version="1.8.0" /> <PackageVersion Include="QRCoder" Version="1.8.0" />
@@ -25,15 +23,15 @@
<PackageVersion Include="Semi.Avalonia" Version="12.1.0.1" /> <PackageVersion Include="Semi.Avalonia" Version="12.1.0.1" />
<PackageVersion Include="Semi.Avalonia.AvaloniaEdit" Version="12.0.0" /> <PackageVersion Include="Semi.Avalonia.AvaloniaEdit" Version="12.0.0" />
<PackageVersion Include="Semi.Avalonia.DataGrid" Version="12.1.0.1" /> <PackageVersion Include="Semi.Avalonia.DataGrid" Version="12.1.0.1" />
<PackageVersion Include="NLog" Version="6.1.4" /> <PackageVersion Include="NLog" Version="6.2.0" />
<PackageVersion Include="sqlite-net-e" Version="1.11.285" /> <PackageVersion Include="sqlite-net-e" Version="1.11.285" />
<PackageVersion Include="Repobot.SQLite.Unofficial" Version="3.53.4.1" /> <PackageVersion Include="Repobot.SQLite.Unofficial" Version="3.53.4.1" />
<PackageVersion Include="TaskScheduler" Version="2.12.2" /> <PackageVersion Include="TaskScheduler" Version="2.12.2" />
<PackageVersion Include="TUnit" Version="1.65.68" />
<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" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -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)
@@ -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,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.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_TunEnabled_ShouldKeepEmbeddedTunRules() public async Task GenerateClientConfigContent_TunEnabled_ShouldKeepEmbeddedTunRules()
{ {
// The embedded tun rules reject local-network noise (NetBIOS/mDNS, multicast). // The embedded tun rules reject local-network noise (NetBIOS/mDNS, multicast).
// They are deserialized into List<Rule4Sbox>, so a schema mismatch in the // They are deserialized into List<Rule4Sbox>, so a schema mismatch in the
@@ -75,22 +65,22 @@ 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.route.rules.Should().Contain( await cfg.route.rules.Should().Contain(
r => r.action == "reject" r => r.action == "reject"
&& r.network != null && r.network.Contains("udp") && r.network != null && r.network.Contains("udp")
&& r.port != null && r.port.Contains(5353), && r.port != null && r.port.Contains(5353),
"the embedded tun rules must reject mDNS/NetBIOS noise"); "the embedded tun rules must reject mDNS/NetBIOS noise");
cfg.route.rules.Should().Contain( await cfg.route.rules.Should().Contain(
r => r.action == "reject" r => r.action == "reject"
&& r.ip_cidr != null && r.ip_cidr.Contains("224.0.0.0/3"), && r.ip_cidr != null && r.ip_cidr.Contains("224.0.0.0/3"),
"the embedded tun rules must reject multicast traffic"); "the embedded tun rules must reject multicast traffic");
} }
[Fact] [Test]
public void GenerateClientConfigContent_TunEnabled_ShouldRejectTrafficToTunOwnAddresses() public async Task GenerateClientConfigContent_TunEnabled_ShouldRejectTrafficToTunOwnAddresses()
{ {
// Regression test: traffic addressed to the TUN interface's own addresses must // Regression test: traffic addressed to the TUN interface's own addresses must
// never reach an outbound. auto_route hijacks the default route, so `direct` // never reach an outbound. auto_route hijacks the default route, so `direct`
@@ -111,17 +101,19 @@ 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 tun = cfg.inbounds.First(i => i.type == "tun"); var tun = cfg.inbounds.First(i => i.type == "tun");
tun.address.Should().NotBeNullOrEmpty(); //tun.address.Should().NotBeNullOrEmpty();
await tun.address.Should().NotBeNull();
await tun.address.Should().NotBeEmpty();
foreach (var address in tun.address!) foreach (var address in tun.address!)
{ {
var self = IPAddress.Parse(address.Split('/').First()); var self = IPAddress.Parse(address.Split('/').First());
var hostBits = self.AddressFamily == AddressFamily.InterNetworkV6 ? 128 : 32; var hostBits = self.AddressFamily == AddressFamily.InterNetworkV6 ? 128 : 32;
var expected = $"{self}/{hostBits}"; var expected = $"{self}/{hostBits}";
cfg.route.rules.Should().Contain( await cfg.route.rules.Should().Contain(
r => r.action == "reject" && r.ip_cidr != null && r.ip_cidr.Contains(expected), 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"); $"traffic to the TUN's own address '{address}' must be rejected, not routed");
} }
@@ -131,12 +123,13 @@ public class CoreConfigSingboxServiceTests
// here leaves room for it, so a prefix match would drop system name lookups too. // here leaves room for it, so a prefix match would drop system name lookups too.
var dropRule = cfg.route.rules.First(r => var dropRule = cfg.route.rules.First(r =>
r.action == "reject" && r.method == "drop" && r.ip_cidr?.Count > 0); r.action == "reject" && r.method == "drop" && r.ip_cidr?.Count > 0);
dropRule.ip_cidr!.Should().OnlyContain(c => //dropRule.ip_cidr!.Should().OnlyContain(c =>
c.EndsWith("/32", StringComparison.Ordinal) || c.EndsWith("/128", StringComparison.Ordinal)); // 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));
} }
[Fact] [Test]
public void GenerateClientConfigContent_BindInterface_ShouldUseDialBindInterface() 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";
@@ -150,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);
@@ -176,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);
@@ -203,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);
@@ -236,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);
@@ -269,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);
@@ -323,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);
@@ -372,21 +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_DirectExpectedIPs_ShouldApplyGeoipAndCidrToDirectDnsRule()
{ {
var config = CoreConfigTestFactory.CreateConfigWithDirectExpectedIPs( var config = CoreConfigTestFactory.CreateConfigWithDirectExpectedIPs(
ECoreType.sing_box, ECoreType.sing_box,
@@ -417,7 +410,7 @@ 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 =>
@@ -426,11 +419,11 @@ public class CoreConfigSingboxServiceTests
&& r.rule_set?.Contains("geosite-cn") == true && r.rule_set?.Contains("geosite-cn") == true
&& r.rule_set?.Contains("geoip-cn") == true) ?? false; && r.rule_set?.Contains("geoip-cn") == true) ?? false;
hasExpectedRule.Should().BeTrue(); await hasExpectedRule.Should().BeTrue();
} }
[Fact] [Test]
public void GenerateClientConfigContent_BootstrapDNS_ShouldConfigurePureIPResolver() public async Task 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);
@@ -441,17 +434,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";
@@ -484,14 +477,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");
@@ -521,21 +514,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 =
@@ -563,7 +556,7 @@ 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 =>
@@ -571,11 +564,11 @@ public class CoreConfigSingboxServiceTests
&& r.ip_cidr?.Contains("192.168.0.0/16") == true && r.ip_cidr?.Contains("192.168.0.0/16") == true
&& r.rule_set?.Contains(expectedRuleSetTag) == true && r.rule_set?.Contains(expectedRuleSetTag) == true
&& r.rule_set?.Contains("geoip-cn") == true) ?? false; && r.rule_set?.Contains("geoip-cn") == true) ?? false;
hasExpectedRule.Should().BeTrue(); 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";
@@ -587,21 +580,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);
@@ -630,22 +623,22 @@ 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() 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);
@@ -658,22 +651,22 @@ 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();
} }
[Fact] [Test]
public void GenerateClientConfigContent_TunSystemStackWithIpv6_ShouldUsePrefixWithPeerAddress() public async Task GenerateClientConfigContent_TunSystemStackWithIpv6_ShouldUsePrefixWithPeerAddress()
{ {
// Regression test for #9820: sing-box fails with "need one more IPv6 address in // 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. // first prefix for system stack" when the TUN inbound uses a /128 IPv6 prefix.
@@ -691,23 +684,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 tun = cfg.inbounds.First(i => i.type == "tun"); 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!) foreach (var address in tun.address!)
{ {
var prefixLength = int.Parse(address[(address.LastIndexOf('/') + 1)..]); var prefixLength = int.Parse(address[(address.LastIndexOf('/') + 1)..]);
var isIpv6 = address.Contains(':'); var isIpv6 = address.Contains(':');
prefixLength.Should().BeLessThanOrEqualTo(isIpv6 ? 126 : 30, await prefixLength.Should().BeLessThanOrEqualTo(isIpv6 ? 126 : 30,
$"'{address}' must leave room for the peer address the system stack derives"); $"'{address}' must leave room for the peer address the system stack derives");
} }
} }
[Fact] [Test]
public void GenerateClientConfigContent_CustomOutbound_ShouldReplaceWithUserCustomOutboundJson() public async Task GenerateClientConfigContent_CustomOutbound_ShouldReplaceWithUserCustomOutboundJson()
{ {
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box); var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
CoreConfigTestFactory.BindAppManagerConfig(config); CoreConfigTestFactory.BindAppManagerConfig(config);
@@ -728,17 +722,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 cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString()); var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString());
cfg.Should().NotBeNull(); await cfg.Should().NotBeNull();
var proxyOutbound = cfg!.outbounds.FirstOrDefault(o => o.tag == Global.ProxyTag); var proxyOutbound = cfg!.outbounds.FirstOrDefault(o => o.tag == Global.ProxyTag);
proxyOutbound.Should().NotBeNull(); await proxyOutbound.Should().NotBeNull();
proxyOutbound!.type.Should().Be("shadowsocks"); await proxyOutbound!.type.Should().BeEqualTo("shadowsocks");
proxyOutbound.server.Should().Be("1.2.3.4"); await proxyOutbound.server.Should().BeEqualTo("1.2.3.4");
proxyOutbound.server_port.Should().Be(8388); await proxyOutbound.server_port.Should().BeEqualTo(8388);
proxyOutbound.method.Should().Be("aes-128-gcm"); await proxyOutbound.method.Should().BeEqualTo("aes-128-gcm");
proxyOutbound.password.Should().Be("custom_password"); 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,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())!;
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(); await outbound.settings.servers.Should().BeNull();
outbound.settings.vnext.Should().BeNull(); await 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 +72,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 +98,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 +133,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 +166,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 +222,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 +271,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 +313,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 +328,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 +345,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 +359,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 +396,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 +408,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 +444,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 +456,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 +489,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 +503,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 +518,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,24 +549,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 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!.streamSettings.sockopt!.domainStrategy.Should().Be("UseIPv4"); await directOutbound!.streamSettings.sockopt!.domainStrategy.Should().BeEqualTo("UseIPv4");
} }
[Theory] [Test]
[InlineData(false)] [Arguments(false)]
[InlineData(true)] [Arguments(true)]
public void GenerateClientConfigContent_Tun_ShouldRouteIPv6IntoTunnel(bool enableIPv6Address) public async Task GenerateClientConfigContent_Tun_ShouldRouteIPv6IntoTunnel(bool enableIPv6Address)
{ {
var config = CoreConfigTestFactory.CreateConfigWithTun(ECoreType.Xray, enableIPv6Address); var config = CoreConfigTestFactory.CreateConfigWithTun(ECoreType.Xray, enableIPv6Address);
CoreConfigTestFactory.BindAppManagerConfig(config); CoreConfigTestFactory.BindAppManagerConfig(config);
@@ -583,20 +576,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 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().Contain("0.0.0.0/0"); await tunInbound!.settings.autoSystemRoutingTable.Should().Contain("0.0.0.0/0");
tunInbound.settings.autoSystemRoutingTable.Should().Contain("::/0"); await tunInbound.settings.autoSystemRoutingTable.Should().Contain("::/0");
// EnableIPv6Address governs the interface address only, never the routing table. // EnableIPv6Address governs the interface address only, never the routing table.
tunInbound.settings.gateway.Should().HaveCount(enableIPv6Address ? 2 : 1); await tunInbound.settings.gateway.Should().HaveCount(enableIPv6Address ? 2 : 1);
} }
[Fact] [Test]
public void GenerateClientConfigContent_TunRouteExcludeAddress_ShouldIncludeIPv6Ranges() public async Task GenerateClientConfigContent_TunRouteExcludeAddress_ShouldIncludeIPv6Ranges()
{ {
var config = CoreConfigTestFactory.CreateConfigWithTunRouteExcludeAddress(ECoreType.Xray); var config = CoreConfigTestFactory.CreateConfigWithTunRouteExcludeAddress(ECoreType.Xray);
config.TunModeItem.EnableIPv6Address = false; config.TunModeItem.EnableIPv6Address = false;
@@ -607,16 +600,16 @@ 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().Contain(x => x.Contains(':')); await tunInbound!.settings.autoSystemRoutingTable.Should().Contain(x => x.Contains(':'));
} }
[Fact] [Test]
public void GenerateClientConfigContent_TunRouteExcludeAddress() public async Task GenerateClientConfigContent_TunRouteExcludeAddress()
{ {
var config = CoreConfigTestFactory.CreateConfigWithTunRouteExcludeAddress(ECoreType.Xray); var config = CoreConfigTestFactory.CreateConfigWithTunRouteExcludeAddress(ECoreType.Xray);
CoreConfigTestFactory.BindAppManagerConfig(config); CoreConfigTestFactory.BindAppManagerConfig(config);
@@ -626,20 +619,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 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");
} }
[Fact] [Test]
public void GenerateClientConfigContent_CustomOutbound_ShouldReplaceWithUserCustomOutboundJson() public async Task GenerateClientConfigContent_CustomOutbound_ShouldReplaceWithUserCustomOutboundJson()
{ {
var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray); var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
CoreConfigTestFactory.BindAppManagerConfig(config); CoreConfigTestFactory.BindAppManagerConfig(config);
@@ -666,14 +659,14 @@ public class CoreConfigV2rayServiceTests
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent(); var result = new CoreConfigV2rayService(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 cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString()); var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString());
cfg.Should().NotBeNull(); await cfg.Should().NotBeNull();
var proxyOutbound = cfg!.outbounds.FirstOrDefault(o => o.tag == Global.ProxyTag); var proxyOutbound = cfg!.outbounds.FirstOrDefault(o => o.tag == Global.ProxyTag);
proxyOutbound.Should().NotBeNull(); await proxyOutbound.Should().NotBeNull();
proxyOutbound!.protocol.Should().Be("shadowsocks"); await proxyOutbound!.protocol.Should().BeEqualTo("shadowsocks");
proxyOutbound.settings.servers.Should().NotBeNull(); await proxyOutbound.settings.servers.Should().NotBeNull();
} }
} }
+443 -54
View File
@@ -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;
}
} }
+37 -42
View File
@@ -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();
}
}
+9 -12
View File
@@ -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);
} }
} }
@@ -1,44 +1,39 @@
using AwesomeAssertions;
using ServiceLib.Enums;
using ServiceLib.Manager;
using Xunit;
namespace ServiceLib.Tests.Manager; namespace ServiceLib.Tests.Manager;
public class CoreManagerTests public class CoreManagerTests
{ {
[Theory] [Test]
[InlineData(ECoreType.sing_box)] [Arguments(ECoreType.sing_box)]
[InlineData(ECoreType.mihomo)] [Arguments(ECoreType.mihomo)]
[InlineData(ECoreType.Xray)] [Arguments(ECoreType.Xray)]
public void ShouldRunAsSudo_TunLaunchOnNonWindows_RequiresElevation(ECoreType coreType) public async Task ShouldRunAsSudo_TunLaunchOnNonWindows_RequiresElevation(ECoreType coreType)
{ {
CoreManager.ShouldRunAsSudo(isTunLaunch: true, coreType, isNonWindows: true).Should().BeTrue(); await CoreManager.ShouldRunAsSudo(isTunLaunch: true, coreType, isNonWindows: true).Should().BeTrue();
} }
[Fact] [Test]
public void ShouldRunAsSudo_NonTunLaunch_ShouldNotElevate() public async Task ShouldRunAsSudo_NonTunLaunch_ShouldNotElevate()
{ {
// Regression guard for the macOS TUN failure: the elevation decision must follow // 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 // 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 // disabled must never elevate, and a launch whose snapshot has TUN enabled must
// elevate regardless of later changes to the live config. // elevate regardless of later changes to the live config.
CoreManager.ShouldRunAsSudo(isTunLaunch: false, ECoreType.sing_box, isNonWindows: true).Should().BeFalse(); await CoreManager.ShouldRunAsSudo(isTunLaunch: false, ECoreType.sing_box, isNonWindows: true).Should().BeFalse();
CoreManager.ShouldRunAsSudo(isTunLaunch: false, ECoreType.Xray, isNonWindows: true).Should().BeFalse(); await CoreManager.ShouldRunAsSudo(isTunLaunch: false, ECoreType.Xray, isNonWindows: true).Should().BeFalse();
} }
[Fact] [Test]
public void ShouldRunAsSudo_OnWindows_ShouldNotElevate() public async Task ShouldRunAsSudo_OnWindows_ShouldNotElevate()
{ {
CoreManager.ShouldRunAsSudo(isTunLaunch: true, ECoreType.sing_box, isNonWindows: false).Should().BeFalse(); await CoreManager.ShouldRunAsSudo(isTunLaunch: true, ECoreType.sing_box, isNonWindows: false).Should().BeFalse();
} }
[Theory] [Test]
[InlineData(ECoreType.v2fly)] [Arguments(ECoreType.v2fly)]
[InlineData(ECoreType.hysteria)] [Arguments(ECoreType.hysteria)]
[InlineData(null)] [Arguments(null)]
public void ShouldRunAsSudo_UnsupportedCoreType_ShouldNotElevate(ECoreType? coreType) public async Task ShouldRunAsSudo_UnsupportedCoreType_ShouldNotElevate(ECoreType? coreType)
{ {
CoreManager.ShouldRunAsSudo(isTunLaunch: true, coreType, isNonWindows: true).Should().BeFalse(); await CoreManager.ShouldRunAsSudo(isTunLaunch: true, coreType, isNonWindows: true).Should().BeFalse();
} }
} }
+17 -17
View File
@@ -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>
+9 -3
View File
@@ -1,4 +1,5 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
namespace ServiceLib.Common; namespace ServiceLib.Common;
@@ -138,7 +139,10 @@ public static class Extension
public static async Task<TOutput> HandleSafe<TInput, TOutput>( public static async Task<TOutput> HandleSafe<TInput, TOutput>(
this Interaction<TInput, TOutput> interaction, this Interaction<TInput, TOutput> interaction,
TInput input, TInput input,
TOutput defaultValue = default!) TOutput defaultValue = default!,
[CallerMemberName] string memberName = "",
[CallerFilePath] string filePath = "",
[CallerLineNumber] int lineNumber = 0)
{ {
try try
{ {
@@ -146,12 +150,14 @@ public static class Extension
} }
catch (UnhandledInteractionException<TInput, TOutput> ex) catch (UnhandledInteractionException<TInput, TOutput> ex)
{ {
Logging.SaveLog($"Unhandled interaction exception for input: {input}", ex); var title = $"Unhandled interaction exception in {memberName} at {filePath}:{lineNumber}";
Logging.SaveLog(title, ex);
return defaultValue; return defaultValue;
} }
catch (Exception ex) catch (Exception ex)
{ {
Logging.SaveLog($"Exception occurred while handling interaction for input: {input}", ex); var title = $"Exception occurred while handling interaction in {memberName} at {filePath}:{lineNumber}, input: {input}";
Logging.SaveLog(title, ex);
return defaultValue; return defaultValue;
} }
} }
+3 -2
View File
@@ -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;
+1
View File
@@ -5,6 +5,7 @@ public sealed class EventChannel<T>
private readonly Signal<T> _signal = new(); private readonly Signal<T> _signal = new();
private readonly Lock _gate = new(); private readonly Lock _gate = new();
private readonly IObservable<T> _observable; private readonly IObservable<T> _observable;
public EventChannel() public EventChannel()
{ {
_observable = _signal.Synchronize(_gate); _observable = _signal.Synchronize(_gate);
@@ -1,5 +1,3 @@
using System.ComponentModel.DataAnnotations;
namespace ServiceLib.Handler.Builder; namespace ServiceLib.Handler.Builder;
public record CoreConfigContextBuilderResult(CoreConfigContext Context, NodeValidatorResult ValidatorResult) public record CoreConfigContextBuilderResult(CoreConfigContext Context, NodeValidatorResult ValidatorResult)
@@ -205,7 +203,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,
}, },
}; };
@@ -386,7 +384,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;
@@ -115,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
@@ -131,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
@@ -147,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);
} }
} }
+11 -16
View File
@@ -115,6 +115,7 @@ 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.FakeIPRange ??= Global.FakeIPRanges.FirstOrDefault();
config.SimpleDNSItem.GlobalFakeIp ??= true; config.SimpleDNSItem.GlobalFakeIp ??= true;
config.SimpleDNSItem.BootstrapDNS ??= Global.DomainPureIPDNSAddress.FirstOrDefault(); config.SimpleDNSItem.BootstrapDNS ??= Global.DomainPureIPDNSAddress.FirstOrDefault();
@@ -921,6 +922,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())
@@ -1752,15 +1754,13 @@ public static class ConfigHandler
{ {
lstProfiles = SingboxFmt.ResolveToCustomOutbound(strData, subRemarks); lstProfiles = SingboxFmt.ResolveToCustomOutbound(strData, subRemarks);
} }
if (lstProfiles.Count == 0) if (lstProfiles.Count > 0)
{ {
return -1; var count = await AddBatchCustomServers(config, lstProfiles, subid, isSub);
} if (count > 0)
{
var count = await AddBatchCustomServers(config, lstProfiles, subid, isSub); return count;
if (count > 0) }
{
return count;
} }
if (HtmlPageFmt.IsHtmlPage(strData)) if (HtmlPageFmt.IsHtmlPage(strData))
@@ -1797,16 +1797,11 @@ public static class ConfigHandler
{ {
ECoreType.Xray => V2rayFmt.ResolveToCustom(strData, subRemarks), ECoreType.Xray => V2rayFmt.ResolveToCustom(strData, subRemarks),
ECoreType.sing_box => SingboxFmt.ResolveToCustom(strData, subRemarks), ECoreType.sing_box => SingboxFmt.ResolveToCustom(strData, subRemarks),
_ => null _ => null,
}; };
if (lstProfiles is not null) if (lstProfiles?.Count > 0)
{ {
if (lstProfiles.Count == 0)
{
return -1;
}
var count = await AddBatchCustomServers(config, lstProfiles, subid, isSub); var count = await AddBatchCustomServers(config, lstProfiles, subid, isSub);
if (count > 0) if (count > 0)
{ {
@@ -2616,7 +2611,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
+6 -3
View File
@@ -1,5 +1,3 @@
using System.Collections.Specialized;
namespace ServiceLib.Handler.Fmt; namespace ServiceLib.Handler.Fmt;
public class BaseFmt public class BaseFmt
@@ -344,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);
} }
} }
+23 -10
View File
@@ -17,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);
@@ -166,15 +170,19 @@ public class Hysteria2Fmt : BaseFmt
} }
if (item.CertSha.IsNullOrEmpty()) if (item.CertSha.IsNullOrEmpty())
{ {
item.CertSha = GetQueryDecoded(query, "pinSHA256"); var pinSHA256 = GetQueryDecoded(query, "pinSHA256");
// NOTE: item.CertSha = pinSHA256;
// To accommodate Xray changes, if (!pinSHA256.IsNullOrEmpty())
// 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. // NOTE:
// Since this won't compromise the overall security model, // To accommodate Xray changes,
// `insecure = true` is automatically set when a fingerprint is detected, // some providers issue self-signed cert links with `insecure = false` and a certificate fingerprint,
// and the value is restored when generating configurations. // breaking interoperability between Xray, official Hysteria 2 client, and sing-box.
item.AllowInsecure = Global.StringTrue; // 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.EchConfigList = GetQueryDecoded(query, "ech");
item.SetProtocolExtra(item.GetProtocolExtra() with item.SetProtocolExtra(item.GetProtocolExtra() with
@@ -219,6 +227,11 @@ public class Hysteria2Fmt : BaseFmt
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()) if (!item.EchConfigList.IsNullOrEmpty())
{ {
dicQuery.Add("ech", Utils.UrlEncode(item.EchConfigList)); dicQuery.Add("ech", Utils.UrlEncode(item.EchConfigList));
+1 -1
View File
@@ -34,9 +34,9 @@ public class SingboxFmt : BaseFmt
if (!isOutbound) if (!isOutbound)
{ {
var fullProfile = ResolveFull(jsonObject, subRemarks); var fullProfile = ResolveFull(jsonObject, subRemarks);
profileList.Add(fullProfile);
if (fullProfile is not null) if (fullProfile is not null)
{ {
profileList.Add(fullProfile);
return profileList; return profileList;
} }
} }
+1 -1
View File
@@ -34,9 +34,9 @@ public class V2rayFmt : BaseFmt
if (!isOutbound) if (!isOutbound)
{ {
var fullProfile = ResolveFull(jsonObject, subRemarks); var fullProfile = ResolveFull(jsonObject, subRemarks);
profileList.Add(fullProfile);
if (fullProfile is not null) if (fullProfile is not null)
{ {
profileList.Add(fullProfile);
return profileList; return profileList;
} }
} }
@@ -31,6 +31,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 +72,10 @@ 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));
}
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 +138,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 +162,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
+117 -41
View File
@@ -55,8 +55,6 @@ public class DownloaderHelper
await using var stream = await downloader.DownloadFileTaskAsync(address: url, cts.Token); 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(cts.Token); return await reader.ReadToEndAsync(cts.Token);
} }
@@ -128,72 +126,150 @@ public class DownloaderHelper
using var cts = new CancellationTokenSource(); using var cts = new CancellationTokenSource();
cts.CancelAfter(TimeSpan.FromSeconds(timeout)); cts.CancelAfter(TimeSpan.FromSeconds(timeout));
await using var stream = await downloader.DownloadFileTaskAsync(address: url, cts.Token); 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, TimeSpan connectTimeout, 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
{
Request = request,
};
var requestConfiguration = new RequestConfiguration() var requestConfiguration = new RequestConfiguration()
{ {
ConnectTimeout = connectTimeout * 1000, ConnectTimeout = (int)connectTimeout.TotalMilliseconds,
Proxy = webProxy 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, TimeSpan connectTimeout, 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 = (int)connectTimeout.TotalMilliseconds,
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 +289,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
@@ -281,6 +281,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);
@@ -279,6 +279,7 @@ public class SimpleDNSItem
public bool? GlobalFakeIp { get; set; } public bool? GlobalFakeIp { get; set; }
public string? FakeIPRange { 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; }
@@ -172,6 +172,8 @@ public class Outboundsettings4Ray
public int? workers { get; set; } public int? workers { get; set; }
public int? version { get; set; } public int? version { get; set; }
public List<string>? remoteDNS { get; set; }
} }
public class WireguardPeer4Ray public class WireguardPeer4Ray
@@ -241,6 +243,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; }
} }
@@ -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);
}
@@ -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; }
+37 -1
View File
@@ -2769,6 +2769,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>
@@ -2779,7 +2797,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 {
@@ -2997,6 +3015,15 @@ namespace ServiceLib.Resx {
} }
} }
/// <summary>
/// 查找类似 DNS 的本地化字符串。
/// </summary>
public static string TbDNS {
get {
return ResourceManager.GetString("TbDNS", resourceCulture);
}
}
/// <summary> /// <summary>
/// 查找类似 DNS Hosts: (&quot;domain1 ip1 ip2&quot; per line) 的本地化字符串。 /// 查找类似 DNS Hosts: (&quot;domain1 ip1 ip2&quot; per line) 的本地化字符串。
/// </summary> /// </summary>
@@ -5031,6 +5058,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>
+1 -1
View File
@@ -1456,7 +1456,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>
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -1456,7 +1456,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>
+1 -1
View File
@@ -1456,7 +1456,7 @@
<value>DNS kustom diaktifkan, pengaturan halaman ini tidak berlaku</value> <value>DNS kustom diaktifkan, pengaturan halaman ini tidak berlaku</value>
</data> </data>
<data name="TbBlockSVCBHTTPSQueriesTips" xml:space="preserve"> <data name="TbBlockSVCBHTTPSQueriesTips" xml:space="preserve">
<value>Blokir pemeriksaan ketersediaan ECH dan HTTP/3 saat diaktifkan</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>Isi template konfigurasi yang benar</value> <value>Isi template konfigurasi yang benar</value>
+14 -2
View File
@@ -1462,7 +1462,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>
@@ -1860,4 +1860,16 @@ The "Get Certificate" action may fail if a self-signed certificate is used or if
<data name="LvCustomCoreType" xml:space="preserve"> <data name="LvCustomCoreType" xml:space="preserve">
<value>Custom config core</value> <value>Custom config core</value>
</data> </data>
</root> <data name="TbXrayOnly" xml:space="preserve">
<value>Xray Only</value>
</data>
<data name="TbBlockAAAAQueries" xml:space="preserve">
<value>Block AAAA Queries</value>
</data>
<data name="TbBlockAAAAQueriesTips" xml:space="preserve">
<value>Block IPv6 queries when enabled</value>
</data>
<data name="TbDNS" xml:space="preserve">
<value>DNS</value>
</data>
</root>
+10 -1
View File
@@ -1462,7 +1462,7 @@
<value>Включён пользовательский DNS — настройки на этой странице не применяются</value> <value>Включён пользовательский DNS — настройки на этой странице не применяются</value>
</data> </data>
<data name="TbBlockSVCBHTTPSQueriesTips" xml:space="preserve"> <data name="TbBlockSVCBHTTPSQueriesTips" xml:space="preserve">
<value>При включении блокирует проверки доступности ECH и HTTP/3</value> <value>При включении блокирует запросы доступности ECH и HTTP/3. В Xray включено всегда</value>
</data> </data>
<data name="FillCorrectConfigTemplateText" xml:space="preserve"> <data name="FillCorrectConfigTemplateText" xml:space="preserve">
<value>Пожалуйста, заполните корректный шаблон конфигурации</value> <value>Пожалуйста, заполните корректный шаблон конфигурации</value>
@@ -1860,4 +1860,13 @@
<data name="LvCustomCoreType" xml:space="preserve"> <data name="LvCustomCoreType" xml:space="preserve">
<value>Ядро пользовательской конфигурации</value> <value>Ядро пользовательской конфигурации</value>
</data> </data>
<data name="TbXrayOnly" xml:space="preserve">
<value>Только Xray</value>
</data>
<data name="TbBlockAAAAQueries" xml:space="preserve">
<value>Блокировать DNS-запросы AAAA</value>
</data>
<data name="TbBlockAAAAQueriesTips" xml:space="preserve">
<value>При включении блокирует DNS-запросы IPv6</value>
</data>
</root> </root>
+13 -1
View File
@@ -1459,7 +1459,7 @@
<value>自定义 DNS 已启用,此页面配置将无效</value> <value>自定义 DNS 已启用,此页面配置将无效</value>
</data> </data>
<data name="TbBlockSVCBHTTPSQueriesTips" xml:space="preserve"> <data name="TbBlockSVCBHTTPSQueriesTips" xml:space="preserve">
<value>开启后将阻止 ECH 和 HTTP/3 可用性查询</value> <value>开启后将阻止 ECH 和 HTTP/3 可用性查询。Xray 固定开启</value>
</data> </data>
<data name="FillCorrectConfigTemplateText" xml:space="preserve"> <data name="FillCorrectConfigTemplateText" xml:space="preserve">
<value>请填写正确的配置模板</value> <value>请填写正确的配置模板</value>
@@ -1861,4 +1861,16 @@
<data name="LvCustomCoreType" xml:space="preserve"> <data name="LvCustomCoreType" xml:space="preserve">
<value>自定义配置核心</value> <value>自定义配置核心</value>
</data> </data>
<data name="TbXrayOnly" xml:space="preserve">
<value>仅 Xray</value>
</data>
<data name="TbBlockAAAAQueries" xml:space="preserve">
<value>阻止 AAAA 查询</value>
</data>
<data name="TbBlockAAAAQueriesTips" xml:space="preserve">
<value>开启后将阻止 IPv6 查询</value>
</data>
<data name="TbDNS" xml:space="preserve">
<value>DNS</value>
</data>
</root> </root>
+2 -2
View File
@@ -1459,7 +1459,7 @@
<value>已啟用自訂 DNS,此頁面的設定將不會生效</value> <value>已啟用自訂 DNS,此頁面的設定將不會生效</value>
</data> </data>
<data name="TbBlockSVCBHTTPSQueriesTips" xml:space="preserve"> <data name="TbBlockSVCBHTTPSQueriesTips" xml:space="preserve">
<value>啟用後將封鎖 ECH 與 HTTP/3 可用性查詢</value> <value>啟用後將封鎖 ECH 與 HTTP/3 可用性查詢。Xray 固定啟用</value>
</data> </data>
<data name="FillCorrectConfigTemplateText" xml:space="preserve"> <data name="FillCorrectConfigTemplateText" xml:space="preserve">
<value>請填寫正確的設定範本</value> <value>請填寫正確的設定範本</value>
@@ -1861,4 +1861,4 @@
<data name="LvCustomCoreType" xml:space="preserve"> <data name="LvCustomCoreType" xml:space="preserve">
<value>自訂設定核心</value> <value>自訂設定核心</value>
</data> </data>
</root> </root>
@@ -1,3 +1,6 @@
using YamlDotNet.Core;
using YamlDotNet.RepresentationModel;
namespace ServiceLib.Services.CoreConfig; namespace ServiceLib.Services.CoreConfig;
/// <summary> /// <summary>
@@ -127,7 +130,27 @@ public class CoreConfigClashService(Config config, bool isTunEnabled)
Logging.SaveLog($"{_tag}-Mixin", ex); Logging.SaveLog($"{_tag}-Mixin", ex);
} }
// Mihomo parses plain values such as 815458e4 as floats, so quote REALITY short IDs.
var originalRealityShortIds = new List<(Dictionary<object, object> RealityOptions, string ShortId)>();
if (fileContent.GetValueOrDefault("proxies") is List<object> proxies)
{
foreach (var proxy in proxies.OfType<Dictionary<object, object>>())
{
if (proxy.GetValueOrDefault("reality-opts") is Dictionary<object, object> realityOptions
&& realityOptions.GetValueOrDefault("short-id") is string shortId
&& !shortId.StartsWith(tagYamlStr2, StringComparison.Ordinal))
{
originalRealityShortIds.Add((realityOptions, shortId));
realityOptions["short-id"] = new YamlScalarNode(shortId) { Style = ScalarStyle.DoubleQuoted };
}
}
}
var txtFileNew = YamlUtils.ToYaml(fileContent).Replace(tagYamlStr2, tagYamlStr3); var txtFileNew = YamlUtils.ToYaml(fileContent).Replace(tagYamlStr2, tagYamlStr3);
foreach (var (realityOptions, shortId) in originalRealityShortIds)
{
realityOptions["short-id"] = shortId;
}
await File.WriteAllTextAsync(fileName, txtFileNew); await File.WriteAllTextAsync(fileName, txtFileNew);
//check again //check again
if (!File.Exists(fileName)) if (!File.Exists(fileName))
@@ -125,7 +125,8 @@ public partial class CoreConfigSingboxService
fullConfigTemplateNode["outbounds"] = customOutboundsNode; fullConfigTemplateNode["outbounds"] = customOutboundsNode;
// Process endpoints // Process endpoints
if (fullConfigTemplateNode["endpoints"] is JsonArray { Count: > 0 } coreConfigEndpointsNode) var coreConfigEndpointsNode = coreConfigNode?["endpoints"] as JsonArray ?? [];
if (coreConfigEndpointsNode is { Count: > 0 })
{ {
var customEndpointsNode = fullConfigTemplateNode["endpoints"] as JsonArray ?? []; var customEndpointsNode = fullConfigTemplateNode["endpoints"] as JsonArray ?? [];
foreach (var endpoint in coreConfigEndpointsNode) foreach (var endpoint in coreConfigEndpointsNode)
@@ -282,13 +282,23 @@ public partial class CoreConfigSingboxService
{ {
query_type = [1, 28], // A and AAAA query_type = [1, 28], // A and AAAA
}, },
fakeipFilterRule fakeipFilterRule,
] ],
}; };
_coreConfig.dns.rules.Add(rule4Fake); _coreConfig.dns.rules.Add(rule4Fake);
} }
if (simpleDnsItem.BlockAAAAQuery == true)
{
_coreConfig.dns.rules.Add(new()
{
query_type = [28],
action = "predefined",
rcode = "NOERROR",
});
}
var routing = context.RoutingItem; var routing = context.RoutingItem;
if (routing == null) if (routing == null)
{ {
@@ -296,8 +306,8 @@ public partial class CoreConfigSingboxService
} }
var rules = JsonUtils.Deserialize<List<RulesItem>>(routing.RuleSet) ?? []; var rules = JsonUtils.Deserialize<List<RulesItem>>(routing.RuleSet) ?? [];
var expectedIPCidr = new List<string>(); var expectedIPCidr = new HashSet<string>();
var expectedIPsRegions = new List<string>(); var expectedIPsRegions = new HashSet<string>();
var regionName = string.Empty; var regionName = string.Empty;
if (!string.IsNullOrEmpty(simpleDnsItem?.DirectExpectedIPs)) if (!string.IsNullOrEmpty(simpleDnsItem?.DirectExpectedIPs))
@@ -306,7 +316,7 @@ public partial class CoreConfigSingboxService
.Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries) .Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries)
.Select(s => s.Trim()) .Select(s => s.Trim())
.Where(s => !string.IsNullOrEmpty(s)) .Where(s => !string.IsNullOrEmpty(s))
.ToList(); .ToHashSet();
foreach (var ip in ipItems) foreach (var ip in ipItems)
{ {
@@ -364,11 +374,11 @@ public partial class CoreConfigSingboxService
rule4ExpectedIPs.geosite = regionGeosite; rule4ExpectedIPs.geosite = regionGeosite;
if (expectedIPsRegions.Count > 0) if (expectedIPsRegions.Count > 0)
{ {
rule4ExpectedIPs.geoip = expectedIPsRegions; rule4ExpectedIPs.geoip = expectedIPsRegions.ToList();
} }
if (expectedIPCidr.Count > 0) if (expectedIPCidr.Count > 0)
{ {
rule4ExpectedIPs.ip_cidr = expectedIPCidr; rule4ExpectedIPs.ip_cidr = expectedIPCidr.ToList();
} }
_coreConfig.dns.rules.Add(rule4ExpectedIPs); _coreConfig.dns.rules.Add(rule4ExpectedIPs);
} }
@@ -82,6 +82,11 @@ public partial class CoreConfigV2rayService
dnsItem.serveStale = simpleDnsItem?.ServeStale is true ? true : null; dnsItem.serveStale = simpleDnsItem?.ServeStale is true ? true : null;
dnsItem.enableParallelQuery = simpleDnsItem?.ParallelQuery is true ? true : null; dnsItem.enableParallelQuery = simpleDnsItem?.ParallelQuery is true ? true : null;
if (simpleDnsItem.BlockAAAAQuery == true)
{
dnsItem.queryStrategy = "UseIPv4";
}
// DNS routing // DNS routing
var directDnsTags = dnsItem.servers var directDnsTags = dnsItem.servers
.Select(server => .Select(server =>
@@ -154,16 +159,16 @@ public partial class CoreConfigV2rayService
var directDNSAddress = ParseDnsAddresses(simpleDNSItem?.DirectDNS, Global.DomainDirectDNSAddress.First()); var directDNSAddress = ParseDnsAddresses(simpleDNSItem?.DirectDNS, Global.DomainDirectDNSAddress.First());
var remoteDNSAddress = ParseDnsAddresses(simpleDNSItem?.RemoteDNS, Global.DomainRemoteDNSAddress.First()); var remoteDNSAddress = ParseDnsAddresses(simpleDNSItem?.RemoteDNS, Global.DomainRemoteDNSAddress.First());
var directDomainList = new List<string>(); var directDomainList = new HashSet<string>();
var directGeositeList = new List<string>(); var directGeositeList = new HashSet<string>();
var proxyDomainList = new List<string>(); var proxyDomainList = new HashSet<string>();
var proxyGeositeList = new List<string>(); var proxyGeositeList = new HashSet<string>();
var expectedDomainList = new List<string>(); var expectedDomainList = new HashSet<string>();
var expectedIPs = new List<string>(); var expectedIPs = new HashSet<string>();
var regionName = string.Empty; var regionName = string.Empty;
var bootstrapDNSAddress = ParseDnsAddresses(simpleDNSItem?.BootstrapDNS, Global.DomainPureIPDNSAddress.First()); var bootstrapDNSAddress = ParseDnsAddresses(simpleDNSItem?.BootstrapDNS, Global.DomainPureIPDNSAddress.First());
var dnsServerDomains = new List<string>(); var dnsServerDomains = new HashSet<string>();
foreach (var dns in directDNSAddress) foreach (var dns in directDNSAddress)
{ {
@@ -189,7 +194,6 @@ public partial class CoreConfigV2rayService
dnsServerDomains.Add($"full:{domain}"); dnsServerDomains.Add($"full:{domain}");
} }
} }
dnsServerDomains = dnsServerDomains.Distinct().ToList();
if (!string.IsNullOrEmpty(simpleDNSItem?.DirectExpectedIPs)) if (!string.IsNullOrEmpty(simpleDNSItem?.DirectExpectedIPs))
{ {
@@ -197,7 +201,7 @@ public partial class CoreConfigV2rayService
.Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries) .Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries)
.Select(s => s.Trim()) .Select(s => s.Trim())
.Where(s => !string.IsNullOrEmpty(s)) .Where(s => !string.IsNullOrEmpty(s))
.ToList(); .ToHashSet();
foreach (var region in from ip in expectedIPs foreach (var region in from ip in expectedIPs
where ip.StartsWith(Global.GeoIPPrefix, StringComparison.OrdinalIgnoreCase) where ip.StartsWith(Global.GeoIPPrefix, StringComparison.OrdinalIgnoreCase)
@@ -264,15 +268,19 @@ public partial class CoreConfigV2rayService
} }
} }
if (context.ProtectDomainList.Count > 0)
{
directDomainList.AddRange(context.ProtectDomainList);
}
dnsItem.servers ??= []; dnsItem.servers ??= [];
var directDnsTagIndex = 1; var directDnsTagIndex = 1;
if (dnsServerDomains.Count > 0)
{
AddDnsServers(bootstrapDNSAddress, dnsServerDomains);
}
if (context.ProtectDomainList.Count > 0)
{
AddDnsServers(directDNSAddress, context.ProtectDomainList, true);
}
if (simpleDNSItem.FakeIP == true) if (simpleDNSItem.FakeIP == true)
{ {
var fakeIPMatchDomain = new HashSet<string>(proxyDomainList); var fakeIPMatchDomain = new HashSet<string>(proxyDomainList);
@@ -286,7 +294,7 @@ public partial class CoreConfigV2rayService
if (fakeIPMatchDomain.Count > 0) if (fakeIPMatchDomain.Count > 0)
{ {
GenFakeDns(); GenFakeDns();
AddDnsServers(["fakedns"], fakeIPMatchDomain.ToList()); AddDnsServers(["fakedns"], fakeIPMatchDomain);
} }
} }
@@ -295,10 +303,6 @@ public partial class CoreConfigV2rayService
AddDnsServers(remoteDNSAddress, proxyGeositeList); AddDnsServers(remoteDNSAddress, proxyGeositeList);
AddDnsServers(directDNSAddress, directGeositeList, true); AddDnsServers(directDNSAddress, directGeositeList, true);
AddDnsServers(directDNSAddress, expectedDomainList, true, expectedIPs); AddDnsServers(directDNSAddress, expectedDomainList, true, expectedIPs);
if (dnsServerDomains.Count > 0)
{
AddDnsServers(bootstrapDNSAddress, dnsServerDomains);
}
var useDirectDns = false; var useDirectDns = false;
@@ -340,7 +344,7 @@ public partial class CoreConfigV2rayService
return addresses.Count > 0 ? addresses : new List<string> { defaultAddress }; return addresses.Count > 0 ? addresses : new List<string> { defaultAddress };
} }
static DnsServer4Ray CreateDnsServer(string dnsAddress, List<string> domains, List<string>? expectedIPs = null) static DnsServer4Ray CreateDnsServer(string dnsAddress, HashSet<string> domains, HashSet<string>? expectedIPs = null)
{ {
var (domain, scheme, port, path) = Utils.ParseUrl(dnsAddress); var (domain, scheme, port, path) = Utils.ParseUrl(dnsAddress);
var domainFinal = dnsAddress; var domainFinal = dnsAddress;
@@ -360,13 +364,13 @@ public partial class CoreConfigV2rayService
address = domainFinal, address = domainFinal,
port = portFinal, port = portFinal,
skipFallback = true, skipFallback = true,
domains = domains.Count > 0 ? domains : null, domains = domains.Count > 0 ? domains.ToList() : null,
expectedIPs = expectedIPs?.Count > 0 ? expectedIPs : null expectedIPs = expectedIPs?.Count > 0 ? expectedIPs.ToList() : null
}; };
return dnsServer; return dnsServer;
} }
void AddDnsServers(List<string> dnsAddresses, List<string> domains, bool isDirectDns = false, List<string>? expectedIPs = null) void AddDnsServers(List<string> dnsAddresses, HashSet<string> domains, bool isDirectDns = false, HashSet<string>? expectedIPs = null)
{ {
if (domains.Count <= 0) if (domains.Count <= 0)
{ {
@@ -297,7 +297,8 @@ public partial class CoreConfigV2rayService
secretKey = _node.Password, secretKey = _node.Password,
reserved = Utils.String2List(protocolExtra.WgReserved)?.Select(s => s.Trim()).Select(int.Parse).ToList(), reserved = Utils.String2List(protocolExtra.WgReserved)?.Select(s => s.Trim()).Select(int.Parse).ToList(),
mtu = protocolExtra.WgMtu > 0 ? protocolExtra.WgMtu : Global.TunMtus.First(), mtu = protocolExtra.WgMtu > 0 ? protocolExtra.WgMtu : Global.TunMtus.First(),
peers = [peer] remoteDNS = Utils.String2List(protocolExtra.WgDns)?.Select(s => s.Trim()).ToList(),
peers = [peer],
}; };
outbound.settings = setting; outbound.settings = setting;
outbound.settings.vnext = null; outbound.settings.vnext = null;
+76 -9
View File
@@ -42,21 +42,88 @@ public class DownloadService
/// <summary> /// <summary>
/// Downloads a file and reports progress through events. /// Downloads a file and reports progress through events.
/// </summary> /// </summary>
public async Task DownloadFileAsync(string url, string fileName, bool blProxy, int downloadTimeout) public async Task DownloadFileAsync(FileDownloadRequest request, bool blProxy, TimeSpan connectTimeout)
{ {
try try
{ {
UpdateCompleted?.Invoke(this, new UpdateResult(false, $"{ResUI.Downloading} {url}")); UpdateCompleted?.Invoke(this, new UpdateResult(false, $"{ResUI.Downloading} {request.FileUrl}"));
var progress = new Progress<double>();
progress.ProgressChanged += (sender, value) => UpdateCompleted?.Invoke(this, new UpdateResult(value > 100, $"...{value}%"));
var webProxy = await GetWebProxy(blProxy); var webProxy = await GetWebProxy(blProxy);
await DownloaderHelper.Instance.DownloadFileAsync(webProxy, await DownloaderHelper.Instance.DownloadFileAsync(webProxy,
url, request,
fileName, OnProgress,
progress, connectTimeout);
downloadTimeout);
void OnProgress(FileDownloadState state)
{
UpdateCompleted?.Invoke(this, new UpdateResult(state.Completed, $"{Utils.HumanFy((long)state.SpeedBytesPerSecond / 1024)}/s | {Utils.HumanFy(state.DownloadedBytes / 1024)}/{Utils.HumanFy(state.TotalBytes / 1024)}"));
}
}
catch (Exception ex)
{
Logging.SaveLog(_tag, ex);
Error?.Invoke(this, new ErrorEventArgs(ex));
if (ex.InnerException != null)
{
Error?.Invoke(this, new ErrorEventArgs(ex.InnerException));
}
}
}
public async Task DownloadSmallFilesAsync(List<FileDownloadRequest> requests, bool blProxy, TimeSpan connectTimeout)
{
try
{
UpdateCompleted?.Invoke(this, new UpdateResult(false, $"{ResUI.Downloading} 0/{requests.Count}"));
var webProxy = await GetWebProxy(blProxy);
await DownloaderHelper.Instance.DownloadSmallFilesAsync(webProxy,
requests,
OnProgress,
connectTimeout);
void OnProgress(ReadOnlyMemory<FileDownloadState> states)
{
var span = states.Span;
var completedCount = 0;
var downloadingStates = new List<FileDownloadState>();
foreach (ref readonly var item in span)
{
if (item.Completed)
{
completedCount++;
}
else if (item.TotalBytes > 0)
{
downloadingStates.Add(item);
}
}
var totalSpeed = downloadingStates.Sum(x => x.SpeedBytesPerSecond);
var totalDownloadedBytes = downloadingStates.Sum(x => x.DownloadedBytes);
var totalTotalBytes = downloadingStates.Sum(x => x.TotalBytes);
var downloadingFileName = string.Join(", ", downloadingStates.Select(x => x.Request.FileName));
var allCompleted = completedCount == span.Length;
if (allCompleted)
{
// check and throw errors if any
FileDownloadState? failedState = null;
foreach (ref readonly var item in span)
{
if (!item.IsFailed)
{
continue;
}
failedState = item;
break;
}
if (failedState?.Error != null)
{
throw failedState.Error;
}
}
UpdateCompleted?.Invoke(this, new UpdateResult(allCompleted, $"{completedCount}/{span.Length} | {Utils.HumanFy((long)totalSpeed / 1024)}/s {Utils.HumanFy(totalDownloadedBytes / 1024)}/{Utils.HumanFy(totalTotalBytes / 1024)} {downloadingFileName}"));
}
} }
catch (Exception ex) catch (Exception ex)
{ {
+87 -50
View File
@@ -37,9 +37,9 @@ public class UpdateService(Config config, Func<bool, string, Task> updateFunc)
await UpdateFunc(false, string.Format(ResUI.MsgParsingSuccessfully, ECoreType.v2rayN)); await UpdateFunc(false, string.Format(ResUI.MsgParsingSuccessfully, ECoreType.v2rayN));
await UpdateFunc(false, result.Msg); await UpdateFunc(false, result.Msg);
url = result.Url.ToString(); url = result.Url!;
fileName = Utils.GetTempPath(Utils.GetGuid()); fileName = Utils.GetTempPath(Utils.GetGuid());
await downloadHandle.DownloadFileAsync(url, fileName, true, _timeout); await downloadHandle.DownloadFileAsync(new() { FileUrl = url, FilePath = fileName }, true, TimeSpan.FromSeconds(_timeout));
} }
else else
{ {
@@ -86,10 +86,10 @@ public class UpdateService(Config config, Func<bool, string, Task> updateFunc)
await UpdateFunc(false, string.Format(ResUI.MsgParsingSuccessfully, type)); await UpdateFunc(false, string.Format(ResUI.MsgParsingSuccessfully, type));
await UpdateFunc(false, result.Msg); await UpdateFunc(false, result.Msg);
url = result.Url.ToString(); url = result.Url!;
var ext = url.Contains(".tar.gz") ? ".tar.gz" : Path.GetExtension(url); var ext = url.Contains(".tar.gz") ? ".tar.gz" : Path.GetExtension(url);
fileName = Utils.GetTempPath(Utils.GetGuid() + ext); fileName = Utils.GetTempPath(Utils.GetGuid() + ext);
await downloadHandle.DownloadFileAsync(url, fileName, true, _timeout); await downloadHandle.DownloadFileAsync(new() { FileUrl = url, FilePath = fileName }, true, TimeSpan.FromSeconds(_timeout));
} }
else else
{ {
@@ -139,9 +139,13 @@ public class UpdateService(Config config, Func<bool, string, Task> updateFunc)
public async Task UpdateGeoFileAll() public async Task UpdateGeoFileAll()
{ {
await UpdateGeoFiles(); var requests = new List<FileDownloadRequest>();
await UpdateOtherFiles(); requests.AddRange(GetGeoFilesRequest());
await UpdateSrsFileAll(); requests.AddRange(GetOtherFilesRequest());
requests.AddRange(await GetSrsFileAllRequest());
// NOTE: srs files are more small, so we reverse the order to ensure a good download experience for the user.
requests.Reverse();
await DownloadGeoFiles(requests);
await UpdateFunc(true, string.Format(ResUI.MsgDownloadGeoFileSuccessfully, "geo")); await UpdateFunc(true, string.Format(ResUI.MsgDownloadGeoFileSuccessfully, "geo"));
} }
@@ -363,41 +367,53 @@ public class UpdateService(Config config, Func<bool, string, Task> updateFunc)
#region Geo private #region Geo private
private async Task UpdateGeoFiles() private List<FileDownloadRequest> GetGeoFilesRequest()
{ {
var geoUrl = string.IsNullOrEmpty(_config?.ConstItem.GeoSourceUrl) var geoUrl = string.IsNullOrEmpty(_config?.ConstItem.GeoSourceUrl)
? Global.GeoUrl ? Global.GeoUrl
: _config.ConstItem.GeoSourceUrl; : _config.ConstItem.GeoSourceUrl;
List<string> files = ["geosite", "geoip"]; List<string> files = ["geosite", "geoip"];
foreach (var geoName in files) return
{ [
var fileName = $"{geoName}.dat"; .. from geoName in files
var targetPath = Utils.GetBinPath($"{fileName}"); let fileName = $"{geoName}.dat"
var url = string.Format(geoUrl, geoName); let targetPath = Utils.GetBinPath($"{fileName}")
let url = string.Format(geoUrl, geoName)
await DownloadGeoFile(url, fileName, targetPath); select new FileDownloadRequest()
} {
FileUrl = url,
FilePath = targetPath,
DisplayFileName = fileName,
},
];
} }
private async Task UpdateOtherFiles() private List<FileDownloadRequest> GetOtherFilesRequest()
{ {
//If it is not in China area, no update is required //If it is not in China area, no update is required
if (_config.ConstItem.GeoSourceUrl.IsNotEmpty()) if (_config.ConstItem.GeoSourceUrl.IsNotEmpty())
{ {
return; return [];
} }
foreach (var url in Global.OtherGeoUrls) return
{ [
var fileName = Path.GetFileName(url); .. Global.OtherGeoUrls.Select(url =>
var targetPath = Utils.GetBinPath($"{fileName}"); {
var fileName = Path.GetFileName(url);
await DownloadGeoFile(url, fileName, targetPath); var targetPath = Utils.GetBinPath($"{fileName}");
} return new FileDownloadRequest()
{
FileUrl = url,
FilePath = targetPath,
DisplayFileName = fileName,
};
}),
];
} }
private async Task UpdateSrsFileAll() private async Task<List<FileDownloadRequest>> GetSrsFileAllRequest()
{ {
var geoipFiles = new List<string>(); var geoipFiles = new List<string>();
var geoSiteFiles = new List<string>(); var geoSiteFiles = new List<string>();
@@ -432,15 +448,12 @@ public class UpdateService(Config config, Func<bool, string, Task> updateFunc)
Directory.CreateDirectory(path); Directory.CreateDirectory(path);
} }
foreach (var item in geoipFiles.Distinct()) return
{ [
await UpdateSrsFile("geoip", item); .. geoipFiles.Distinct().Select(f => (type: "geoip", file: f))
} .Concat(geoSiteFiles.Distinct().Select(f => (type: "geosite", file: f)))
.Select(item => GetSrsFileRequest(item.type, item.file)),
foreach (var item in geoSiteFiles.Distinct()) ];
{
await UpdateSrsFile("geosite", item);
}
} }
private void AddPrefixedItems(List<string>? items, string prefix, List<string> output) private void AddPrefixedItems(List<string>? items, string prefix, List<string> output)
@@ -500,7 +513,7 @@ public class UpdateService(Config config, Func<bool, string, Task> updateFunc)
} }
} }
private async Task UpdateSrsFile(string type, string srsName) private FileDownloadRequest GetSrsFileRequest(string type, string srsName)
{ {
var srsUrl = string.IsNullOrEmpty(_config.ConstItem.SrsSourceUrl) var srsUrl = string.IsNullOrEmpty(_config.ConstItem.SrsSourceUrl)
? Global.SingboxRulesetUrl ? Global.SingboxRulesetUrl
@@ -510,33 +523,57 @@ public class UpdateService(Config config, Func<bool, string, Task> updateFunc)
var targetPath = Path.Combine(Utils.GetBinPath("srss"), fileName); var targetPath = Path.Combine(Utils.GetBinPath("srss"), fileName);
var url = string.Format(srsUrl, type, $"{type}-{srsName}", srsName); var url = string.Format(srsUrl, type, $"{type}-{srsName}", srsName);
await DownloadGeoFile(url, fileName, targetPath); return new FileDownloadRequest()
{
FileUrl = url,
FilePath = targetPath,
DisplayFileName = fileName,
};
} }
private async Task DownloadGeoFile(string url, string fileName, string targetPath) private async Task DownloadGeoFiles(List<FileDownloadRequest> requests)
{ {
var tmpFileName = Utils.GetTempPath(Utils.GetGuid()); var tmpFilePathDict = new Dictionary<string, string>();
var tmpFileRequests = new List<FileDownloadRequest>();
foreach (var request in requests)
{
var tmpFilePath = Utils.GetTempPath(Utils.GetGuid());
tmpFilePathDict[request.FilePath] = tmpFilePath;
tmpFileRequests.Add(request with
{
FilePath = tmpFilePath,
});
}
DownloadService downloadHandle = new(); DownloadService downloadHandle = new();
downloadHandle.UpdateCompleted += (sender2, args) => downloadHandle.UpdateCompleted += (sender2, args) =>
{ {
if (args.Success) if (args.Success)
{ {
_ = UpdateFunc(false, string.Format(ResUI.MsgDownloadGeoFileSuccessfully, fileName)); //_ = UpdateFunc(false, string.Format(ResUI.MsgDownloadGeoFileSuccessfully, fileName));
try foreach (var request in requests)
{ {
if (File.Exists(tmpFileName)) try
{ {
File.Copy(tmpFileName, targetPath, true); //if (File.Exists(tmpFileName))
//{
// File.Copy(tmpFileName, targetPath, true);
File.Delete(tmpFileName); // File.Delete(tmpFileName);
//await UpdateFunc(true, ""); // //await UpdateFunc(true, "");
//}
var tmpFileName = tmpFilePathDict[request.FilePath];
if (File.Exists(tmpFileName))
{
File.Copy(tmpFileName, request.FilePath, true);
File.Delete(tmpFileName);
}
}
catch (Exception ex)
{
_ = UpdateFunc(false, ex.Message);
} }
}
catch (Exception ex)
{
_ = UpdateFunc(false, ex.Message);
} }
} }
else else
@@ -549,7 +586,7 @@ public class UpdateService(Config config, Func<bool, string, Task> updateFunc)
_ = UpdateFunc(false, args.GetException().Message); _ = UpdateFunc(false, args.GetException().Message);
}; };
await downloadHandle.DownloadFileAsync(url, tmpFileName, true, _timeout); await downloadHandle.DownloadSmallFilesAsync(tmpFileRequests, true, TimeSpan.FromSeconds(_timeout));
} }
#endregion Geo private #endregion Geo private
@@ -70,6 +70,9 @@ public partial class AddServerViewModel : MyReactiveObject, ICloseable
[Reactive] [Reactive]
public partial int WgMtu { get; set; } public partial int WgMtu { get; set; }
[Reactive]
public partial string WgDns { get; set; }
[Reactive] [Reactive]
public partial bool Uot { get; set; } public partial bool Uot { get; set; }
@@ -310,6 +313,7 @@ public partial class AddServerViewModel : MyReactiveObject, ICloseable
WgInterfaceAddress = protocolExtra.WgInterfaceAddress ?? string.Empty; WgInterfaceAddress = protocolExtra.WgInterfaceAddress ?? string.Empty;
WgReserved = protocolExtra.WgReserved ?? string.Empty; WgReserved = protocolExtra.WgReserved ?? string.Empty;
WgMtu = protocolExtra.WgMtu ?? 1280; WgMtu = protocolExtra.WgMtu ?? 1280;
WgDns = protocolExtra.WgDns ?? string.Empty;
Uot = protocolExtra.Uot ?? false; Uot = protocolExtra.Uot ?? false;
CongestionControl = protocolExtra.CongestionControl ?? string.Empty; CongestionControl = protocolExtra.CongestionControl ?? string.Empty;
InsecureConcurrency = protocolExtra.InsecureConcurrency > 0 ? protocolExtra.InsecureConcurrency : null; InsecureConcurrency = protocolExtra.InsecureConcurrency > 0 ? protocolExtra.InsecureConcurrency : null;
@@ -431,6 +435,7 @@ public partial class AddServerViewModel : MyReactiveObject, ICloseable
WgInterfaceAddress = WgInterfaceAddress.NullIfEmpty(), WgInterfaceAddress = WgInterfaceAddress.NullIfEmpty(),
WgReserved = WgReserved.NullIfEmpty(), WgReserved = WgReserved.NullIfEmpty(),
WgMtu = WgMtu >= 576 ? WgMtu : null, WgMtu = WgMtu >= 576 ? WgMtu : null,
WgDns = WgDns.NullIfEmpty(),
Uot = Uot ? true : null, Uot = Uot ? true : null,
CongestionControl = CongestionControl.NullIfEmpty(), CongestionControl = CongestionControl.NullIfEmpty(),
InsecureConcurrency = InsecureConcurrency > 0 ? InsecureConcurrency : null, InsecureConcurrency = InsecureConcurrency > 0 ? InsecureConcurrency : null,
@@ -9,6 +9,7 @@ public partial class DNSSettingViewModel : MyReactiveObject, ICloseable
[Reactive] public partial bool FakeIP { get; set; } [Reactive] public partial bool FakeIP { get; set; }
[Reactive] public partial string FakeIPRange { get; set; } [Reactive] public partial string FakeIPRange { get; set; }
[Reactive] public partial bool BlockBindingQuery { get; set; } [Reactive] public partial bool BlockBindingQuery { get; set; }
[Reactive] public partial bool BlockAAAAQuery { get; set; }
[Reactive] public partial string DirectDNS { get; set; } [Reactive] public partial string DirectDNS { get; set; }
[Reactive] public partial string RemoteDNS { get; set; } [Reactive] public partial string RemoteDNS { get; set; }
[Reactive] public partial string BootstrapDNS { get; set; } [Reactive] public partial string BootstrapDNS { get; set; }
@@ -74,6 +75,7 @@ public partial class DNSSettingViewModel : MyReactiveObject, ICloseable
FakeIP = item.FakeIP ?? false; FakeIP = item.FakeIP ?? false;
FakeIPRange = item.FakeIPRange ?? string.Empty; FakeIPRange = item.FakeIPRange ?? string.Empty;
BlockBindingQuery = item.BlockBindingQuery ?? false; BlockBindingQuery = item.BlockBindingQuery ?? false;
BlockAAAAQuery = item.BlockAAAAQuery ?? false;
DirectDNS = item.DirectDNS ?? string.Empty; DirectDNS = item.DirectDNS ?? string.Empty;
RemoteDNS = item.RemoteDNS ?? string.Empty; RemoteDNS = item.RemoteDNS ?? string.Empty;
BootstrapDNS = item.BootstrapDNS ?? string.Empty; BootstrapDNS = item.BootstrapDNS ?? string.Empty;
@@ -108,6 +110,7 @@ public partial class DNSSettingViewModel : MyReactiveObject, ICloseable
_config.SimpleDNSItem.FakeIP = FakeIP; _config.SimpleDNSItem.FakeIP = FakeIP;
_config.SimpleDNSItem.FakeIPRange = FakeIPRange; _config.SimpleDNSItem.FakeIPRange = FakeIPRange;
_config.SimpleDNSItem.BlockBindingQuery = BlockBindingQuery; _config.SimpleDNSItem.BlockBindingQuery = BlockBindingQuery;
_config.SimpleDNSItem.BlockAAAAQuery = BlockAAAAQuery;
_config.SimpleDNSItem.DirectDNS = DirectDNS; _config.SimpleDNSItem.DirectDNS = DirectDNS;
_config.SimpleDNSItem.RemoteDNS = RemoteDNS; _config.SimpleDNSItem.RemoteDNS = RemoteDNS;
_config.SimpleDNSItem.BootstrapDNS = BootstrapDNS; _config.SimpleDNSItem.BootstrapDNS = BootstrapDNS;
@@ -3,7 +3,7 @@ namespace ServiceLib.ViewModels;
public partial class ProfilesSelectViewModel : MyReactiveObject, ICloseable public partial class ProfilesSelectViewModel : MyReactiveObject, ICloseable
{ {
public event EventHandler? RequestClose; public event EventHandler? RequestClose;
public Interaction<RxVoid, RxVoid> ProfilesFocusInteraction { get; } = new(); public Interaction<RxVoid, RxVoid> ProfilesFocusInteraction { get; } = new();
#region private prop #region private prop
@@ -9,6 +9,7 @@ public partial class SubEditViewModel : MyReactiveObject, ICloseable
[Reactive] [Reactive]
public partial string CustomCoreType { get; set; } public partial string CustomCoreType { get; set; }
[Reactive] [Reactive]
public partial string PrevProfile { get; set; } public partial string PrevProfile { get; set; }
+2 -2
View File
@@ -16,7 +16,7 @@ internal static class MacAppUtils
public static bool IsWindowMiniaturized(Window window) public static bool IsWindowMiniaturized(Window window)
=> window.TryGetPlatformHandle() is IMacOSTopLevelPlatformHandle { NSWindow: not 0 } handle => window.TryGetPlatformHandle() is IMacOSTopLevelPlatformHandle { NSWindow: not 0 } handle
&& objc_msgSend_bool(handle.NSWindow, sel_registerName("isMiniaturized")); && objc_msgSend_bool(handle.NSWindow, sel_registerName("isMiniaturized"));
[DllImport(LibObjC)] [DllImport(LibObjC)]
private static extern nint objc_getClass(string name); private static extern nint objc_getClass(string name);
@@ -29,7 +29,7 @@ internal static class MacAppUtils
[DllImport(LibObjC, EntryPoint = "objc_msgSend")] [DllImport(LibObjC, EntryPoint = "objc_msgSend")]
[return: MarshalAs(UnmanagedType.I1)] [return: MarshalAs(UnmanagedType.I1)]
private static extern bool objc_msgSend_bool(nint receiver, nint selector); private static extern bool objc_msgSend_bool(nint receiver, nint selector);
[DllImport(LibObjC, EntryPoint = "objc_msgSend")] [DllImport(LibObjC, EntryPoint = "objc_msgSend")]
private static extern void objc_msgSend(nint receiver, nint selector, nint argument); private static extern void objc_msgSend(nint receiver, nint selector, nint argument);
} }
@@ -1,4 +1,3 @@
using ServiceLib.Models.Entities;
using System.Diagnostics; using System.Diagnostics;
using v2rayN.Desktop.Manager; using v2rayN.Desktop.Manager;
using v2rayN.Desktop.ViewModels; using v2rayN.Desktop.ViewModels;
@@ -580,7 +580,7 @@
Grid.Row="2" Grid.Row="2"
ColumnDefinitions="300,Auto" ColumnDefinitions="300,Auto"
IsVisible="False" IsVisible="False"
RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,Auto"> RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto">
<TextBlock <TextBlock
Grid.Row="1" Grid.Row="1"
@@ -649,7 +649,7 @@
Grid.Column="1" Grid.Column="1"
Width="400" Width="400"
Margin="{StaticResource Margin4}" Margin="{StaticResource Margin4}"
PlaceholderText="Ipv4,Ipv6" /> PlaceholderText="Ipv4, Ipv6" />
<TextBlock <TextBlock
Grid.Row="6" Grid.Row="6"
@@ -665,6 +665,21 @@
Margin="{StaticResource Margin4}" Margin="{StaticResource Margin4}"
HorizontalAlignment="Left" HorizontalAlignment="Left"
PlaceholderText="1280" /> PlaceholderText="1280" />
<TextBlock
Grid.Row="7"
Grid.Column="0"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
Text="{x:Static resx:ResUI.TbDNS}" />
<TextBox
x:Name="txtDns"
Grid.Row="7"
Grid.Column="1"
Width="400"
Margin="{StaticResource Margin4}"
HorizontalAlignment="Left"
PlaceholderText="1.1.1.1, 2606:4700:4700::1111" />
</Grid> </Grid>
<Grid <Grid
x:Name="gridAnytls" x:Name="gridAnytls"
@@ -1265,8 +1280,10 @@
Width="400" Width="400"
Margin="{StaticResource Margin4}" Margin="{StaticResource Margin4}"
VerticalAlignment="Center" VerticalAlignment="Center"
Text="{x:Static resx:ResUI.TbCertSha256Tips}" TextWrapping="Wrap">
TextWrapping="Wrap" /> <Run Text="{x:Static resx:ResUI.TbCertSha256Tips}" />
<Run FontWeight="Bold" Text="{x:Static resx:ResUI.TbXrayOnly}" />
</TextBlock>
<TextBox <TextBox
x:Name="txtCertSha256Pinning" x:Name="txtCertSha256Pinning"
Width="400" Width="400"
@@ -1,5 +1,4 @@
using v2rayN.Desktop.Base; using v2rayN.Desktop.Base;
using v2rayN.Desktop.Common;
namespace v2rayN.Desktop.Views; namespace v2rayN.Desktop.Views;
@@ -126,6 +125,7 @@ public partial class AddServerWindow : WindowBase<AddServerViewModel>
this.Bind(ViewModel, vm => vm.WgReserved, v => v.txtPath9.Text).DisposeWith(currentTypeDisposables); this.Bind(ViewModel, vm => vm.WgReserved, v => v.txtPath9.Text).DisposeWith(currentTypeDisposables);
this.Bind(ViewModel, vm => vm.WgInterfaceAddress, v => v.txtRequestHost9.Text).DisposeWith(currentTypeDisposables); this.Bind(ViewModel, vm => vm.WgInterfaceAddress, v => v.txtRequestHost9.Text).DisposeWith(currentTypeDisposables);
this.Bind(ViewModel, vm => vm.WgMtu, v => v.txtShortId9.Text).DisposeWith(currentTypeDisposables); this.Bind(ViewModel, vm => vm.WgMtu, v => v.txtShortId9.Text).DisposeWith(currentTypeDisposables);
this.Bind(ViewModel, vm => vm.WgDns, v => v.txtDns.Text).DisposeWith(currentTypeDisposables);
break; break;
case EConfigType.Anytls: case EConfigType.Anytls:
@@ -242,7 +242,7 @@
x:Name="gridAdvancedDNSSettings" x:Name="gridAdvancedDNSSettings"
Margin="{StaticResource Margin8}" Margin="{StaticResource Margin8}"
ColumnDefinitions="Auto,Auto,*" ColumnDefinitions="Auto,Auto,*"
RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,Auto,*"> RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,*">
<TextBlock <TextBlock
x:Name="txtAdvancedDNSSettingsInvalid" x:Name="txtAdvancedDNSSettingsInvalid"
@@ -338,17 +338,38 @@
Grid.Column="0" Grid.Column="0"
Margin="{StaticResource Margin4}" Margin="{StaticResource Margin4}"
VerticalAlignment="Center" VerticalAlignment="Center"
Text="{x:Static resx:ResUI.TbBlockAAAAQueries}" />
<ToggleSwitch
x:Name="togBlockAAAAQuery"
Grid.Row="5"
Grid.Column="1"
Margin="{StaticResource Margin4}"
HorizontalAlignment="Left"
VerticalAlignment="Center" />
<TextBlock
Grid.Row="5"
Grid.Column="2"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
Text="{x:Static resx:ResUI.TbBlockAAAAQueriesTips}"
TextWrapping="Wrap" />
<TextBlock
Grid.Row="6"
Grid.Column="0"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
Text="{x:Static resx:ResUI.TbValidateDirectExpectedIPs}" /> Text="{x:Static resx:ResUI.TbValidateDirectExpectedIPs}" />
<ComboBox <ComboBox
x:Name="cmbDirectExpectedIPs" x:Name="cmbDirectExpectedIPs"
Grid.Row="5" Grid.Row="6"
Grid.Column="1" Grid.Column="1"
Width="200" Width="200"
Margin="{StaticResource Margin4}" Margin="{StaticResource Margin4}"
VerticalAlignment="Center" VerticalAlignment="Center"
IsEditable="True" /> IsEditable="True" />
<TextBlock <TextBlock
Grid.Row="5" Grid.Row="6"
Grid.Column="2" Grid.Column="2"
Margin="{StaticResource Margin4}" Margin="{StaticResource Margin4}"
VerticalAlignment="Center" VerticalAlignment="Center"
@@ -356,7 +377,7 @@
TextWrapping="Wrap" /> TextWrapping="Wrap" />
<TextBlock <TextBlock
Grid.Row="6" Grid.Row="7"
Grid.Column="0" Grid.Column="0"
Grid.ColumnSpan="3" Grid.ColumnSpan="3"
Margin="{StaticResource Margin4}" Margin="{StaticResource Margin4}"
@@ -365,7 +386,7 @@
<TextBox <TextBox
x:Name="txtHosts" x:Name="txtHosts"
Grid.Row="7" Grid.Row="8"
Grid.Column="0" Grid.Column="0"
Grid.ColumnSpan="3" Grid.ColumnSpan="3"
Margin="{StaticResource Margin4}" Margin="{StaticResource Margin4}"
@@ -35,6 +35,7 @@ public partial class DNSSettingWindow : WindowBase<DNSSettingViewModel>
this.Bind(ViewModel, vm => vm.FakeIP, v => v.togFakeIP.IsChecked).DisposeWith(disposables); this.Bind(ViewModel, vm => vm.FakeIP, v => v.togFakeIP.IsChecked).DisposeWith(disposables);
this.Bind(ViewModel, vm => vm.FakeIPRange, v => v.cmbFakeIPRange.Text).DisposeWith(disposables); this.Bind(ViewModel, vm => vm.FakeIPRange, v => v.cmbFakeIPRange.Text).DisposeWith(disposables);
this.Bind(ViewModel, vm => vm.BlockBindingQuery, v => v.togBlockBindingQuery.IsChecked).DisposeWith(disposables); this.Bind(ViewModel, vm => vm.BlockBindingQuery, v => v.togBlockBindingQuery.IsChecked).DisposeWith(disposables);
this.Bind(ViewModel, vm => vm.BlockAAAAQuery, v => v.togBlockAAAAQuery.IsChecked).DisposeWith(disposables);
this.Bind(ViewModel, vm => vm.DirectDNS, v => v.cmbDirectDNS.Text).DisposeWith(disposables); this.Bind(ViewModel, vm => vm.DirectDNS, v => v.cmbDirectDNS.Text).DisposeWith(disposables);
this.Bind(ViewModel, vm => vm.RemoteDNS, v => v.cmbRemoteDNS.Text).DisposeWith(disposables); this.Bind(ViewModel, vm => vm.RemoteDNS, v => v.cmbRemoteDNS.Text).DisposeWith(disposables);
this.Bind(ViewModel, vm => vm.BootstrapDNS, v => v.cmbBootstrapDNS.Text).DisposeWith(disposables); this.Bind(ViewModel, vm => vm.BootstrapDNS, v => v.cmbBootstrapDNS.Text).DisposeWith(disposables);
@@ -28,6 +28,8 @@ public partial class ProfilesView : ReactiveUserControl<ProfilesViewModel>
lstProfiles.SetValue(DragDrop.AllowDropProperty, true); lstProfiles.SetValue(DragDrop.AllowDropProperty, true);
lstProfiles.AddHandler(PointerPressedEvent, LstProfiles_PointerPressed, RoutingStrategies.Bubble, true); lstProfiles.AddHandler(PointerPressedEvent, LstProfiles_PointerPressed, RoutingStrategies.Bubble, true);
lstProfiles.AddHandler(PointerMovedEvent, LstProfiles_PointerMoved, RoutingStrategies.Bubble, true);
lstProfiles.AddHandler(PointerReleasedEvent, LstProfiles_PointerReleased, RoutingStrategies.Bubble, true);
lstProfiles.AddHandler(DragDrop.DragOverEvent, LstProfiles_DragOver, RoutingStrategies.Bubble); lstProfiles.AddHandler(DragDrop.DragOverEvent, LstProfiles_DragOver, RoutingStrategies.Bubble);
lstProfiles.AddHandler(DragDrop.DropEvent, LstProfiles_Drop, RoutingStrategies.Bubble); lstProfiles.AddHandler(DragDrop.DropEvent, LstProfiles_Drop, RoutingStrategies.Bubble);
} }
@@ -435,31 +437,66 @@ public partial class ProfilesView : ReactiveUserControl<ProfilesViewModel>
#region Drag and Drop #region Drag and Drop
private static readonly DataFormat<object> LstProfilesRowFormat = private static readonly DataFormat<ProfileItemModel> LstProfilesRowFormat =
DataFormat.CreateInProcessFormat<object>("LstProfilesRow"); DataFormat.CreateInProcessFormat<ProfileItemModel>("LstProfilesRow");
private (Point, PointerPressedEventArgs)? _dragStartPoint;
private async void LstProfiles_PointerPressed(object? sender, PointerPressedEventArgs e) private void LstProfiles_PointerPressed(object? sender, PointerPressedEventArgs e)
{
var properties = e.GetCurrentPoint(this).Properties;
if (properties.IsLeftButtonPressed)
{
_dragStartPoint = (e.GetPosition(this), e);
}
}
private async void LstProfiles_PointerMoved(object? sender, PointerEventArgs e)
{ {
try try
{ {
if (e.Source is not Visual visualSource) if (_dragStartPoint == null)
{ {
return; return;
} }
var properties = e.GetCurrentPoint(this).Properties;
if (!properties.IsLeftButtonPressed)
{
_dragStartPoint = null;
return;
}
var currentPoint = e.GetPosition(this);
var startPoint = _dragStartPoint.Value.Item1;
var delta = startPoint - currentPoint;
var threshold = new Vector(4, 4);
if (!(Math.Abs(delta.X) >= threshold.X) && !(Math.Abs(delta.Y) >= threshold.Y))
{
return;
}
var dragStartEventArgs = _dragStartPoint.Value.Item2;
_dragStartPoint = null;
if (e.Source is not Visual visualSource)
{
return;
}
var row = visualSource.FindAncestorOfType<DataGridRow>(true); var row = visualSource.FindAncestorOfType<DataGridRow>(true);
if (row?.DataContext == null) if (row?.DataContext == null)
{ {
return; return;
} }
if (e.GetCurrentPoint(row).Properties.IsLeftButtonPressed) e.Handled = true;
{
var dragData = new DataTransfer(); var dragData = new DataTransfer();
var item = DataTransferItem.Create(LstProfilesRowFormat, row.DataContext); var item = DataTransferItem.Create(LstProfilesRowFormat, row.DataContext as ProfileItemModel);
dragData.Add(item);
await DragDrop.DoDragDropAsync(e, dragData, DragDropEffects.Move); dragData.Add(item);
}
await DragDrop.DoDragDropAsync(dragStartEventArgs, dragData, DragDropEffects.Move);
} }
catch catch
{ {
@@ -467,6 +504,11 @@ public partial class ProfilesView : ReactiveUserControl<ProfilesViewModel>
} }
} }
private void LstProfiles_PointerReleased(object? sender, PointerReleasedEventArgs e)
{
_dragStartPoint = null;
}
private void LstProfiles_DragOver(object? sender, DragEventArgs e) private void LstProfiles_DragOver(object? sender, DragEventArgs e)
{ {
if (!e.DataTransfer.Contains(LstProfilesRowFormat)) if (!e.DataTransfer.Contains(LstProfilesRowFormat))
+23 -3
View File
@@ -748,6 +748,7 @@
<RowDefinition Height="Auto" /> <RowDefinition Height="Auto" />
<RowDefinition Height="Auto" /> <RowDefinition Height="Auto" />
<RowDefinition Height="Auto" /> <RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions> </Grid.RowDefinitions>
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="300" /> <ColumnDefinition Width="300" />
@@ -830,7 +831,7 @@
Grid.Column="1" Grid.Column="1"
Width="400" Width="400"
Margin="{StaticResource Margin4}" Margin="{StaticResource Margin4}"
materialDesign:HintAssist.Hint="Ipv4,Ipv6" materialDesign:HintAssist.Hint="Ipv4, Ipv6"
Style="{StaticResource DefTextBox}" /> Style="{StaticResource DefTextBox}" />
<TextBlock <TextBlock
@@ -849,6 +850,23 @@
HorizontalAlignment="Left" HorizontalAlignment="Left"
materialDesign:HintAssist.Hint="1280" materialDesign:HintAssist.Hint="1280"
Style="{StaticResource DefTextBox}" /> Style="{StaticResource DefTextBox}" />
<TextBlock
Grid.Row="7"
Grid.Column="0"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
Style="{StaticResource ToolbarTextBlock}"
Text="{x:Static resx:ResUI.TbDNS}" />
<TextBox
x:Name="txtDns"
Grid.Row="7"
Grid.Column="1"
Width="400"
Margin="{StaticResource Margin4}"
HorizontalAlignment="Left"
materialDesign:HintAssist.Hint="1.1.1.1, 2606:4700:4700::1111"
Style="{StaticResource DefTextBox}" />
</Grid> </Grid>
<Grid <Grid
x:Name="gridAnytls" x:Name="gridAnytls"
@@ -1593,8 +1611,10 @@
Margin="{StaticResource Margin4}" Margin="{StaticResource Margin4}"
VerticalAlignment="Center" VerticalAlignment="Center"
Style="{StaticResource ToolbarTextBlock}" Style="{StaticResource ToolbarTextBlock}"
Text="{x:Static resx:ResUI.TbCertSha256Tips}" TextWrapping="Wrap">
TextWrapping="Wrap" /> <Run Text="{x:Static resx:ResUI.TbCertSha256Tips}" />
<Run FontWeight="Bold" Text="{x:Static resx:ResUI.TbXrayOnly}" />
</TextBlock>
<TextBox <TextBox
x:Name="txtCertSha256Pinning" x:Name="txtCertSha256Pinning"
Width="400" Width="400"
@@ -124,6 +124,7 @@ public partial class AddServerWindow
this.Bind(ViewModel, vm => vm.WgReserved, v => v.txtPath9.Text).DisposeWith(currentTypeDisposables); this.Bind(ViewModel, vm => vm.WgReserved, v => v.txtPath9.Text).DisposeWith(currentTypeDisposables);
this.Bind(ViewModel, vm => vm.WgInterfaceAddress, v => v.txtRequestHost9.Text).DisposeWith(currentTypeDisposables); this.Bind(ViewModel, vm => vm.WgInterfaceAddress, v => v.txtRequestHost9.Text).DisposeWith(currentTypeDisposables);
this.Bind(ViewModel, vm => vm.WgMtu, v => v.txtShortId9.Text).DisposeWith(currentTypeDisposables); this.Bind(ViewModel, vm => vm.WgMtu, v => v.txtShortId9.Text).DisposeWith(currentTypeDisposables);
this.Bind(ViewModel, vm => vm.WgDns, v => v.txtDns.Text).DisposeWith(currentTypeDisposables);
break; break;
case EConfigType.Anytls: case EConfigType.Anytls:
+27 -4
View File
@@ -278,6 +278,7 @@
<RowDefinition Height="Auto" /> <RowDefinition Height="Auto" />
<RowDefinition Height="Auto" /> <RowDefinition Height="Auto" />
<RowDefinition Height="Auto" /> <RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" /> <RowDefinition Height="*" />
</Grid.RowDefinitions> </Grid.RowDefinitions>
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
@@ -387,17 +388,39 @@
Margin="{StaticResource Margin8}" Margin="{StaticResource Margin8}"
VerticalAlignment="Center" VerticalAlignment="Center"
Style="{StaticResource ToolbarTextBlock}" Style="{StaticResource ToolbarTextBlock}"
Text="{x:Static resx:ResUI.TbBlockAAAAQueries}" />
<ToggleButton
x:Name="togBlockAAAAQuery"
Grid.Row="5"
Grid.Column="1"
Margin="{StaticResource Margin8}"
HorizontalAlignment="Left" />
<TextBlock
Grid.Row="5"
Grid.Column="2"
Margin="{StaticResource Margin8}"
VerticalAlignment="Center"
Style="{StaticResource ToolbarTextBlock}"
Text="{x:Static resx:ResUI.TbBlockAAAAQueriesTips}"
TextWrapping="Wrap" />
<TextBlock
Grid.Row="6"
Grid.Column="0"
Margin="{StaticResource Margin8}"
VerticalAlignment="Center"
Style="{StaticResource ToolbarTextBlock}"
Text="{x:Static resx:ResUI.TbValidateDirectExpectedIPs}" /> Text="{x:Static resx:ResUI.TbValidateDirectExpectedIPs}" />
<ComboBox <ComboBox
x:Name="cmbDirectExpectedIPs" x:Name="cmbDirectExpectedIPs"
Grid.Row="5" Grid.Row="6"
Grid.Column="1" Grid.Column="1"
Width="200" Width="200"
Margin="{StaticResource Margin8}" Margin="{StaticResource Margin8}"
IsEditable="True" IsEditable="True"
Style="{StaticResource DefComboBox}" /> Style="{StaticResource DefComboBox}" />
<TextBlock <TextBlock
Grid.Row="5" Grid.Row="6"
Grid.Column="2" Grid.Column="2"
Margin="{StaticResource Margin8}" Margin="{StaticResource Margin8}"
VerticalAlignment="Center" VerticalAlignment="Center"
@@ -406,7 +429,7 @@
TextWrapping="Wrap" /> TextWrapping="Wrap" />
<TextBlock <TextBlock
Grid.Row="6" Grid.Row="7"
Grid.Column="0" Grid.Column="0"
Grid.ColumnSpan="3" Grid.ColumnSpan="3"
Margin="{StaticResource Margin8}" Margin="{StaticResource Margin8}"
@@ -415,7 +438,7 @@
Text="{x:Static resx:ResUI.TbDNSHostsConfig}" /> Text="{x:Static resx:ResUI.TbDNSHostsConfig}" />
<TextBox <TextBox
x:Name="txtHosts" x:Name="txtHosts"
Grid.Row="7" Grid.Row="8"
Grid.Column="0" Grid.Column="0"
Grid.ColumnSpan="3" Grid.ColumnSpan="3"
Margin="{StaticResource Margin8}" Margin="{StaticResource Margin8}"
@@ -31,6 +31,7 @@ public partial class DNSSettingWindow
this.Bind(ViewModel, vm => vm.FakeIP, v => v.togFakeIP.IsChecked).DisposeWith(disposables); this.Bind(ViewModel, vm => vm.FakeIP, v => v.togFakeIP.IsChecked).DisposeWith(disposables);
this.Bind(ViewModel, vm => vm.FakeIPRange, v => v.cmbFakeIPRange.Text).DisposeWith(disposables); this.Bind(ViewModel, vm => vm.FakeIPRange, v => v.cmbFakeIPRange.Text).DisposeWith(disposables);
this.Bind(ViewModel, vm => vm.BlockBindingQuery, v => v.togBlockBindingQuery.IsChecked).DisposeWith(disposables); this.Bind(ViewModel, vm => vm.BlockBindingQuery, v => v.togBlockBindingQuery.IsChecked).DisposeWith(disposables);
this.Bind(ViewModel, vm => vm.BlockAAAAQuery, v => v.togBlockAAAAQuery.IsChecked).DisposeWith(disposables);
this.Bind(ViewModel, vm => vm.DirectDNS, v => v.cmbDirectDNS.Text).DisposeWith(disposables); this.Bind(ViewModel, vm => vm.DirectDNS, v => v.cmbDirectDNS.Text).DisposeWith(disposables);
this.Bind(ViewModel, vm => vm.RemoteDNS, v => v.cmbRemoteDNS.Text).DisposeWith(disposables); this.Bind(ViewModel, vm => vm.RemoteDNS, v => v.cmbRemoteDNS.Text).DisposeWith(disposables);
this.Bind(ViewModel, vm => vm.BootstrapDNS, v => v.cmbBootstrapDNS.Text).DisposeWith(disposables); this.Bind(ViewModel, vm => vm.BootstrapDNS, v => v.cmbBootstrapDNS.Text).DisposeWith(disposables);