Compare commits

..

14 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
28 changed files with 715 additions and 63 deletions
+17 -5
View File
@@ -1,12 +1,27 @@
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: permissions:
@@ -19,9 +34,6 @@ jobs:
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 uses: actions/setup-dotnet@v6
+1 -1
View File
@@ -2,4 +2,4 @@
"test": { "test": {
"runner": "Microsoft.Testing.Platform" "runner": "Microsoft.Testing.Platform"
} }
} }
+1 -1
View File
@@ -1,7 +1,7 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<Version>7.24.8</Version> <Version>7.24.9</Version>
</PropertyGroup> </PropertyGroup>
<PropertyGroup> <PropertyGroup>
+1 -1
View File
@@ -27,7 +27,7 @@
<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.38" /> <PackageVersion Include="TUnit" Version="1.65.68" />
<PackageVersion Include="TUnit.Assertions.Should" Version="1.65.38-beta" /> <PackageVersion Include="TUnit.Assertions.Should" Version="1.65.38-beta" />
<PackageVersion Include="WebDav.Client" Version="2.9.0" /> <PackageVersion Include="WebDav.Client" Version="2.9.0" />
<PackageVersion Include="YamlDotNet" Version="18.1.0" /> <PackageVersion Include="YamlDotNet" Version="18.1.0" />
+395 -2
View File
@@ -2,6 +2,46 @@ namespace ServiceLib.Tests.Fmt;
public class FmtHandlerTests public class FmtHandlerTests
{ {
/// <summary>
/// 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] [Test]
public async Task GetShareUriAndResolveConfig_Vmess_ShouldRoundTripBasicFields() public async Task GetShareUriAndResolveConfig_Vmess_ShouldRoundTripBasicFields()
{ {
@@ -62,6 +102,165 @@ public class FmtHandlerTests
await resolved.Password.Should().BeEqualTo(source.Password); await resolved.Password.Should().BeEqualTo(source.Password);
} }
[Test]
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] [Test]
public async Task ResolveConfig_UnsupportedProtocol_ShouldReturnNull() public async Task ResolveConfig_UnsupportedProtocol_ShouldReturnNull()
{ {
@@ -82,14 +281,70 @@ public class FmtHandlerTests
await uri.Should().BeNull(); await uri.Should().BeNull();
} }
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);
await uri.Should().NotBeNull();
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) 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); var uri = FmtHandler.GetShareUri(source);
await uri.Should().NotBeNull(); await uri.Should().NotBeNull();
await uri.Should().NotBeEmpty(); await uri.Should().NotBeEmpty();
await uri!.StartsWith(Global.ProtocolShares[source.ConfigType], StringComparison.OrdinalIgnoreCase).Should() await uri!.StartsWith(expectedPrefix, StringComparison.OrdinalIgnoreCase).Should().BeTrue();
.BeTrue();
var resolved = FmtHandler.ResolveConfig(uri, out var msg); var resolved = FmtHandler.ResolveConfig(uri, out var msg);
@@ -166,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;
}
} }
@@ -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();
}
}
@@ -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");
}
}
@@ -11,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
@@ -35,6 +36,7 @@ public class WireguardFmtTests
await first.GetProtocolExtra().WgReserved.Should().BeEqualTo("1, 2, 3"); await first.GetProtocolExtra().WgReserved.Should().BeEqualTo("1, 2, 3");
await first.GetProtocolExtra().WgInterfaceAddress.Should().BeEqualTo("10.0.0.2/32, fd00::2/128"); await first.GetProtocolExtra().WgInterfaceAddress.Should().BeEqualTo("10.0.0.2/32, fd00::2/128");
await first.GetProtocolExtra().WgMtu.Should().BeEqualTo(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];
await second.Address.Should().BeEqualTo("example.com"); await second.Address.Should().BeEqualTo("example.com");
+3 -1
View File
@@ -200,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;
+14 -17
View File
@@ -922,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())
@@ -1753,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))
@@ -1801,15 +1800,13 @@ public static class ConfigHandler
_ => null, _ => null,
}; };
if ((lstProfiles?.Count ?? 0) == 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;
} }
return await SaveCustomRawFileServer(config, strData, subid, isSub, subItem, customCoreType); return await SaveCustomRawFileServer(config, strData, subid, isSub, subItem, customCoreType);
@@ -2614,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 -1
View File
@@ -342,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);
} }
} }
+10 -1
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);
@@ -223,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));
@@ -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
@@ -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);
@@ -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
@@ -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; }
+9
View File
@@ -3015,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>
+3
View File
@@ -1869,4 +1869,7 @@ The "Get Certificate" action may fail if a self-signed certificate is used or if
<data name="TbBlockAAAAQueriesTips" xml:space="preserve"> <data name="TbBlockAAAAQueriesTips" xml:space="preserve">
<value>Block IPv6 queries when enabled</value> <value>Block IPv6 queries when enabled</value>
</data> </data>
<data name="TbDNS" xml:space="preserve">
<value>DNS</value>
</data>
</root> </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>Block ECH and HTTP/3 availability checks when enabled. Always enabled in Xray</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>
@@ -1870,4 +1870,7 @@
<data name="TbBlockAAAAQueriesTips" xml:space="preserve"> <data name="TbBlockAAAAQueriesTips" xml:space="preserve">
<value>开启后将阻止 IPv6 查询</value> <value>开启后将阻止 IPv6 查询</value>
</data> </data>
<data name="TbDNS" xml:space="preserve">
<value>DNS</value>
</data>
</root> </root>
@@ -306,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))
@@ -316,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)
{ {
@@ -374,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);
} }
@@ -159,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)
{ {
@@ -194,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))
{ {
@@ -202,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)
@@ -269,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);
@@ -291,7 +294,7 @@ public partial class CoreConfigV2rayService
if (fakeIPMatchDomain.Count > 0) if (fakeIPMatchDomain.Count > 0)
{ {
GenFakeDns(); GenFakeDns();
AddDnsServers(["fakedns"], fakeIPMatchDomain.ToList()); AddDnsServers(["fakedns"], fakeIPMatchDomain);
} }
} }
@@ -300,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;
@@ -345,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;
@@ -365,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;
@@ -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,
@@ -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"
@@ -125,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:
+19 -1
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"
@@ -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: