From ef46f4e7e6fcb767a25306986e7212b9e719f2f0 Mon Sep 17 00:00:00 2001 From: DHR60 <192860629+DHR60@users.noreply.github.com> Date: Mon, 22 Jun 2026 12:52:47 +0000 Subject: [PATCH] Hysteria Realm & Gecko (#9516) * Hysteria Realm * Core config * Fix * Fix * Add Gecko support --- v2rayN/ServiceLib.Tests/Fmt/HyRealmTests.cs | 61 +++++ v2rayN/ServiceLib/Global.cs | 12 + v2rayN/ServiceLib/Handler/ConfigHandler.cs | 45 +++- v2rayN/ServiceLib/Handler/Fmt/FmtHandler.cs | 5 + v2rayN/ServiceLib/Handler/Fmt/Hysteria2Fmt.cs | 191 +++++++++++--- .../Models/CoreConfigs/SingboxConfig.cs | 10 + .../Models/CoreConfigs/V2rayConfig.cs | 4 + v2rayN/ServiceLib/Models/Dto/HyRealm.cs | 106 ++++++++ .../Models/Entities/ProtocolExtraItem.cs | 6 + v2rayN/ServiceLib/Resx/ResUI.Designer.cs | 236 ++++++++++-------- v2rayN/ServiceLib/Resx/ResUI.resx | 12 + v2rayN/ServiceLib/Resx/ResUI.zh-Hans.resx | 14 +- .../Singbox/SingboxOutboundService.cs | 23 +- .../CoreConfig/V2ray/V2rayOutboundService.cs | 32 ++- .../ViewModels/AddServerViewModel.cs | 25 ++ .../Views/AddServerWindow.axaml | 92 +++++-- .../Views/AddServerWindow.axaml.cs | 3 + v2rayN/v2rayN/Views/AddServerWindow.xaml | 74 ++++-- v2rayN/v2rayN/Views/AddServerWindow.xaml.cs | 3 + 19 files changed, 763 insertions(+), 191 deletions(-) create mode 100644 v2rayN/ServiceLib.Tests/Fmt/HyRealmTests.cs create mode 100644 v2rayN/ServiceLib/Models/Dto/HyRealm.cs diff --git a/v2rayN/ServiceLib.Tests/Fmt/HyRealmTests.cs b/v2rayN/ServiceLib.Tests/Fmt/HyRealmTests.cs new file mode 100644 index 00000000..33bc1828 --- /dev/null +++ b/v2rayN/ServiceLib.Tests/Fmt/HyRealmTests.cs @@ -0,0 +1,61 @@ +using AwesomeAssertions; +using Xunit; + +namespace ServiceLib.Tests.Fmt; + +public class HyRealmTests +{ + [Fact] + public void TryParse_ShouldParseValidRealm() + { + 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); + result.Should().BeTrue(); + realm.Should().NotBeNull(); + + realm.IsHttp.Should().BeFalse(); + realm.Token.Should().Be("public"); + realm.RendezvousHost.Should().Be("realm.hy2.io"); + realm.RendezvousPort.Should().Be(443); + realm.RealmName.Should().Be("57f9be7c-2810-4f5b-8cb9-260bc84d6c90"); + realm.StunList.Should().HaveCount(2); + realm.StunList.Should().Contain("example.stun:3478"); + realm.StunList.Should().Contain("example2.stun:3478"); + } + + [Fact] + public void ToUri_ShouldGenerateValidUri() + { + var realm = new HyRealm( + IsHttp: false, + Token: "public", + RendezvousHost: "realm.hy2.io", + RendezvousPort: 443, + RealmName: "57f9be7c-2810-4f5b-8cb9-260bc84d6c90", + StunList: ["example.stun:3478", "example2.stun:3478"] + ); + var uri = realm.ToUri(); + uri.Should().Contain("realm://public@realm.hy2.io"); + uri.Should().Contain("/57f9be7c-2810-4f5b-8cb9-260bc84d6c90"); + uri.Should().Contain("stun=example.stun:3478"); + uri.Should().Contain("stun=example2.stun:3478"); + } + + [Fact] + public void GetShareUriAndResolveConfig_Hy2Realm_ShouldRoundTripBasicFields() + { + 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); + resolved.Should().NotBeNull(); + resolved.Password.Should().Be("your_password"); + var result = HyRealm.TryParse(resolved.GetProtocolExtra().Hy2RealmUrl, out var realm); + result.Should().BeTrue(); + realm.Should().NotBeNull(); + realm.Token.Should().Be("mytoken"); + + // To uri + var uri = Hysteria2Fmt.ToUri(resolved); + uri.Should().Contain("hysteria2+realm://mytoken@rendezvous.example.com"); + uri.Should().EndWith("#remark"); + } +} diff --git a/v2rayN/ServiceLib/Global.cs b/v2rayN/ServiceLib/Global.cs index 437ceeee..ebffeb26 100644 --- a/v2rayN/ServiceLib/Global.cs +++ b/v2rayN/ServiceLib/Global.cs @@ -210,6 +210,10 @@ public class Global public const string Hysteria2ProtocolShare = "hy2://"; + public const string Hysteria2RealmProtocolShare = "hysteria2+realm://"; + + public const string Hysteria2HttpRealmProtocolShare = "hysteria2+realm+http://"; + public const string NaiveHttpsProtocolShare = "naive+https://"; public const string NaiveQuicProtocolShare = "naive+quic://"; @@ -677,6 +681,14 @@ public class Global "mcbe:bedrock.talonmc.net", ]; + public static readonly List DefaultRealmStunList = + [ + "turn.cloudflare.com:3478", + "stun.nextcloud.com:3478", + "stun.sip.us:3478", + "global.stun.twilio.com:3478", + ]; + public static readonly List OutboundTags = [ ProxyTag, diff --git a/v2rayN/ServiceLib/Handler/ConfigHandler.cs b/v2rayN/ServiceLib/Handler/ConfigHandler.cs index 30efbfc4..bc4914b7 100644 --- a/v2rayN/ServiceLib/Handler/ConfigHandler.cs +++ b/v2rayN/ServiceLib/Handler/ConfigHandler.cs @@ -725,12 +725,51 @@ public static class ConfigHandler { return -1; } - profileItem.SetProtocolExtra(profileItem.GetProtocolExtra() with + var protocolExtra = profileItem.GetProtocolExtra(); + profileItem.SetProtocolExtra(protocolExtra with { - SalamanderPass = profileItem.GetProtocolExtra().SalamanderPass?.TrimEx(), - HopInterval = profileItem.GetProtocolExtra().HopInterval?.TrimEx(), + SalamanderPass = protocolExtra.SalamanderPass?.TrimEx(), + HopInterval = protocolExtra.HopInterval?.TrimEx(), }); + if (!protocolExtra.Hy2RealmUrl.IsNullOrEmpty()) + { + var realmResult = HyRealm.TryParse(protocolExtra.Hy2RealmUrl, out var realm); + if (!realmResult || realm is null) + { + return -1; + } + if (realm.StunList.Count == 0) + { + realm = realm with + { + StunList = Global.DefaultRealmStunList, + }; + } + profileItem.SetProtocolExtra(profileItem.GetProtocolExtra() with + { + Hy2RealmUrl = realm.ToUri(), + }); + } + var isGecko = !protocolExtra.GeckoMinPacketSize.IsNullOrEmpty() || !protocolExtra.GeckoMaxPacketSize.IsNullOrEmpty(); + if (isGecko) + { + var minPacketSize = protocolExtra.GeckoMinPacketSize.ToInt(); + var maxPacketSize = protocolExtra.GeckoMaxPacketSize.ToInt(); + if (minPacketSize <= 0 + || minPacketSize > maxPacketSize + || maxPacketSize > 2048) + { + minPacketSize = 512; + maxPacketSize = 1200; + } + profileItem.SetProtocolExtra(profileItem.GetProtocolExtra() with + { + GeckoMinPacketSize = minPacketSize.ToString(), + GeckoMaxPacketSize = maxPacketSize.ToString(), + }); + } + await AddServerCommon(config, profileItem, toFile); return 0; diff --git a/v2rayN/ServiceLib/Handler/Fmt/FmtHandler.cs b/v2rayN/ServiceLib/Handler/Fmt/FmtHandler.cs index 7691d874..f2210464 100644 --- a/v2rayN/ServiceLib/Handler/Fmt/FmtHandler.cs +++ b/v2rayN/ServiceLib/Handler/Fmt/FmtHandler.cs @@ -72,6 +72,11 @@ public class FmtHandler { return Hysteria2Fmt.Resolve(str, out msg); } + else if (str.StartsWith(Global.Hysteria2RealmProtocolShare) + || str.StartsWith(Global.Hysteria2HttpRealmProtocolShare)) + { + return Hysteria2Fmt.ResolveRealm(str, out msg); + } else if (str.StartsWith(Global.ProtocolShares[EConfigType.TUIC])) { return TuicFmt.Resolve(str, out msg); diff --git a/v2rayN/ServiceLib/Handler/Fmt/Hysteria2Fmt.cs b/v2rayN/ServiceLib/Handler/Fmt/Hysteria2Fmt.cs index aed4548f..318ed4d4 100644 --- a/v2rayN/ServiceLib/Handler/Fmt/Hysteria2Fmt.cs +++ b/v2rayN/ServiceLib/Handler/Fmt/Hysteria2Fmt.cs @@ -1,3 +1,5 @@ +using System.Collections.Specialized; + namespace ServiceLib.Handler.Fmt; public class Hysteria2Fmt : BaseFmt @@ -23,15 +25,7 @@ public class Hysteria2Fmt : BaseFmt var query = Utils.ParseQueryString(url.Query); ResolveUriQuery(query, ref item); - if (item.CertSha.IsNullOrEmpty()) - { - item.CertSha = GetQueryDecoded(query, "pinSHA256"); - } - item.SetProtocolExtra(item.GetProtocolExtra() with - { - Ports = GetQueryDecoded(query, "mport"), - SalamanderPass = GetQueryDecoded(query, "obfs-password"), - }); + ResolveHy2UriQuery(query, ref item); return item; } @@ -42,8 +36,11 @@ public class Hysteria2Fmt : BaseFmt { return null; } - - var url = string.Empty; + var protocolExtraItem = item.GetProtocolExtra(); + if (!protocolExtraItem.Hy2RealmUrl.TrimEx().IsNullOrEmpty()) + { + return ToUriForRealm(item); + } var remark = string.Empty; if (item.Remarks.IsNotEmpty()) @@ -52,27 +49,7 @@ public class Hysteria2Fmt : BaseFmt } var dicQuery = new Dictionary(); ToUriQueryLite(item, ref dicQuery); - var protocolExtraItem = item.GetProtocolExtra(); - - if (!protocolExtraItem.SalamanderPass.IsNullOrEmpty()) - { - dicQuery.Add("obfs", "salamander"); - dicQuery.Add("obfs-password", Utils.UrlEncode(protocolExtraItem.SalamanderPass)); - } - if (!protocolExtraItem.Ports.IsNullOrEmpty()) - { - dicQuery.Add("mport", Utils.UrlEncode(protocolExtraItem.Ports.Replace(':', '-'))); - } - if (!item.CertSha.IsNullOrEmpty()) - { - var sha = item.CertSha; - var idx = sha.IndexOf(','); - if (idx > 0) - { - sha = sha[..idx]; - } - dicQuery.Add("pinSHA256", Utils.UrlEncode(sha)); - } + ToHy2UriQuery(item, ref dicQuery); return ToUri(EConfigType.Hysteria2, item.Address, item.Port, item.Password, dicQuery, remark); } @@ -94,4 +71,154 @@ public class Hysteria2Fmt : BaseFmt return null; } + + public static ProfileItem? ResolveRealm(string str, out string msg) + { + msg = ResUI.ConfigurationFormatIncorrect; + ProfileItem item = new() + { + ConfigType = EConfigType.Hysteria2 + }; + var realmStr = str["hysteria2+".Length..]; + var result = HyRealm.TryParse(realmStr, out var realm); + if (!result || realm == null) + { + return null; + } + + var url = Utils.TryUri(str); + if (url == null) + { + return null; + } + + item.Address = realm.RendezvousHost; + item.Port = realm.RendezvousPort; + item.Remarks = url.GetComponents(UriComponents.Fragment, UriFormat.Unescaped); + + var query = Utils.ParseQueryString(url.Query); + ResolveUriQuery(query, ref item); + var authPassword = GetQueryDecoded(query, "auth"); + if (authPassword.IsNullOrEmpty()) + { + return null; + } + item.Password = authPassword; + ResolveHy2UriQuery(query, ref item); + item.SetProtocolExtra(item.GetProtocolExtra() with + { + Hy2RealmUrl = realm.ToUri(), + }); + + return item; + } + + public static string? ToUriForRealm(ProfileItem? item) + { + if (item == null) + { + return null; + } + var protocolExtraItem = item.GetProtocolExtra(); + var result = HyRealm.TryParse(protocolExtraItem.Hy2RealmUrl, out var realm); + if (!result || realm == null) + { + return null; + } + + var remark = string.Empty; + if (item.Remarks.IsNotEmpty()) + { + remark = "#" + Utils.UrlEncode(item.Remarks); + } + var dicQuery = new Dictionary(); + ToUriQueryLite(item, ref dicQuery); + ToHy2UriQuery(item, ref dicQuery); + + dicQuery.Add("auth", Utils.UrlEncode(item.Password)); + + var queryBuilder = new StringBuilder(); + queryBuilder.Append('?'); + foreach (var kv in dicQuery) + { + queryBuilder.Append(kv.Key); + queryBuilder.Append('='); + queryBuilder.Append(kv.Value); + queryBuilder.Append('&'); + } + foreach (var stun in realm.StunList) + { + queryBuilder.Append("stun="); + queryBuilder.Append(Utils.UrlEncode(stun)); + queryBuilder.Append('&'); + } + var query = queryBuilder.ToString().TrimEnd('&'); + + var url = $"{Utils.UrlEncode(realm.Token)}@{GetIpv6(realm.RendezvousHost)}:{realm.RendezvousPort}"; + var scheme = realm.IsHttp ? Global.Hysteria2HttpRealmProtocolShare : Global.Hysteria2RealmProtocolShare; + return $"{scheme}{url}/{realm.RealmName}{query}{remark}"; + } + + private static void ResolveHy2UriQuery(NameValueCollection query, ref ProfileItem item) + { + if (item.CertSha.IsNullOrEmpty()) + { + item.CertSha = GetQueryDecoded(query, "pinSHA256"); + } + item.SetProtocolExtra(item.GetProtocolExtra() with + { + Ports = GetQueryDecoded(query, "mport"), + SalamanderPass = GetQueryDecoded(query, "obfs-password"), + // NOTE: The "PacketSize" parameter is not defined by the official URI Scheme, may remove or rename it in the future. + GeckoMinPacketSize = GetQueryDecoded(query, "minPacketSize"), + GeckoMaxPacketSize = GetQueryDecoded(query, "maxPacketSize"), + }); + if (GetQueryDecoded(query, "obfs") == "gecko") + { + // Ensure the "PacketSize" parameters are present for gecko obfs. + var protocolExtraItem = item.GetProtocolExtra(); + if (protocolExtraItem.GeckoMinPacketSize.IsNullOrEmpty()) + { + protocolExtraItem = protocolExtraItem with + { + GeckoMinPacketSize = "512", + }; + } + if (protocolExtraItem.GeckoMaxPacketSize.IsNullOrEmpty()) + { + protocolExtraItem = protocolExtraItem with + { + GeckoMaxPacketSize = "1200", + }; + } + item.SetProtocolExtra(protocolExtraItem); + } + } + + private static void ToHy2UriQuery(ProfileItem item, ref Dictionary dicQuery) + { + if (!item.CertSha.IsNullOrEmpty() + && !item.CertSha.Contains(',')) + { + var sha = item.CertSha; + dicQuery.Add("pinSHA256", Utils.UrlEncode(sha)); + } + var protocolExtraItem = item.GetProtocolExtra(); + var isGecko = !protocolExtraItem.GeckoMinPacketSize.IsNullOrEmpty() || !protocolExtraItem.GeckoMaxPacketSize.IsNullOrEmpty(); + if (!protocolExtraItem.SalamanderPass.IsNullOrEmpty()) + { + dicQuery.Add("obfs", isGecko ? "gecko" : "salamander"); + dicQuery.Add("obfs-password", Utils.UrlEncode(protocolExtraItem.SalamanderPass)); + if (isGecko) + { + // NOTE: The "PacketSize" parameter is not defined by the official URI Scheme, may remove or rename it in the future. + dicQuery.Add("minPacketSize", protocolExtraItem.GeckoMinPacketSize); + dicQuery.Add("maxPacketSize", protocolExtraItem.GeckoMaxPacketSize); + } + } + if (!protocolExtraItem.Ports.IsNullOrEmpty()) + { + dicQuery.Add("mport", Utils.UrlEncode(protocolExtraItem.Ports.Replace(':', '-'))); + } + } } diff --git a/v2rayN/ServiceLib/Models/CoreConfigs/SingboxConfig.cs b/v2rayN/ServiceLib/Models/CoreConfigs/SingboxConfig.cs index 136e8f27..82890a18 100644 --- a/v2rayN/ServiceLib/Models/CoreConfigs/SingboxConfig.cs +++ b/v2rayN/ServiceLib/Models/CoreConfigs/SingboxConfig.cs @@ -248,6 +248,15 @@ public class HyObfs4Sbox { public string? type { get; set; } public string? password { get; set; } + public int? min_packet_size { get; set; } + public int? max_packet_size { get; set; } +} + +public class HyRealm4Sbox +{ + public string? server_url { get; set; } + public string? token { get; set; } + public string? realm_id { get; set; } } public class Server4Sbox : BaseServer4Sbox @@ -334,6 +343,7 @@ public abstract class DialFields4Sbox public Multiplex4Sbox? multiplex { get; set; } public Transport4Sbox? transport { get; set; } public HyObfs4Sbox? obfs { get; set; } + public HyRealm4Sbox? realm { get; set; } } public abstract class BaseServer4Sbox : DialFields4Sbox diff --git a/v2rayN/ServiceLib/Models/CoreConfigs/V2rayConfig.cs b/v2rayN/ServiceLib/Models/CoreConfigs/V2rayConfig.cs index 7db4b0fb..bf57b248 100644 --- a/v2rayN/ServiceLib/Models/CoreConfigs/V2rayConfig.cs +++ b/v2rayN/ServiceLib/Models/CoreConfigs/V2rayConfig.cs @@ -507,6 +507,10 @@ public class MaskSettings4Ray public string? password { get; set; } + public string? url { get; set; } + public List? stunServers { get; set; } + public string? packetSize { get; set; } + // fragment public string? packets { get; set; } diff --git a/v2rayN/ServiceLib/Models/Dto/HyRealm.cs b/v2rayN/ServiceLib/Models/Dto/HyRealm.cs new file mode 100644 index 00000000..7dbce1a3 --- /dev/null +++ b/v2rayN/ServiceLib/Models/Dto/HyRealm.cs @@ -0,0 +1,106 @@ +namespace ServiceLib.Models.Dto; + +// realm://@[:port]/?stun=[:port]&stun=[:port]... +// realm+http://@[:port]/?stun=[:port]&stun=[:port]... +// example: +// realm://public@realm.hy2.io/57f9be7c-2810-4f5b-8cb9-260bc84d6c90?stun=example.stun:3478&stun=example2.stun:3478 +public record HyRealm( + bool IsHttp, + string Token, + string RendezvousHost, + int RendezvousPort, + string RealmName, + List StunList +) +{ + public string RendezvousHostPort => RendezvousPort > 0 ? $"{RendezvousHost}:{RendezvousPort}" : RendezvousHost; + + public static bool TryParse(string? str, out HyRealm? realm) + { + realm = null; + if (str == null) + { + return false; + } + try + { + var isHttp = str.StartsWith("realm+http://"); + var prefix = isHttp ? "realm+http://" : "realm://"; + if (!str.StartsWith(prefix)) + { + return false; + } + var uri = new Uri(str); + var token = Utils.UrlDecode(uri.UserInfo); + var rendezvousHost = uri.Host; + var rendezvousPort = uri.IsDefaultPort ? (isHttp ? 80 : 443) : uri.Port; + var realmName = uri.AbsolutePath.TrimStart('/'); + var stunList = new List(); + var query = uri.Query; + if (!query.IsNullOrEmpty()) + { + if (query.StartsWith('?')) + { + query = query.Substring(1); + } + var pairs = query.Split('&', StringSplitOptions.RemoveEmptyEntries); + foreach (var pair in pairs) + { + var idx = pair.IndexOf('='); + if (idx <= 0) + { + continue; + } + var key = pair.Substring(0, idx); + if (!key.Equals("stun", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + var val = pair.Substring(idx + 1); + stunList.Add(Utils.UrlDecode(val)); + } + } + realm = new HyRealm(isHttp, token, rendezvousHost, rendezvousPort, realmName, stunList); + return true; + } + catch + { + return false; + } + } + + public string ToUri() + { + var prefix = IsHttp ? "realm+http://" : "realm://"; + var uriBuilder = new UriBuilder + { + Scheme = IsHttp ? "http" : "realm", + Host = RendezvousHost, + Port = RendezvousPort, + Path = "/" + RealmName, + UserName = Utils.UrlEncode(Token), + }; + if (StunList is { Count: > 0 }) + { + // var query = string.Join('&', StunList.Select(s => "stun=" + Utils.UrlEncode(s))); + // NOTE: maybe we don't need to encode the stun host:port, since it should be a valid URI component already, and encoding will make it unreadable + var query = string.Join('&', StunList.Select(s => "stun=" + s)); + uriBuilder.Query = query; + } + return prefix + uriBuilder.Uri.GetComponents( + UriComponents.UserInfo | UriComponents.HostAndPort | UriComponents.PathAndQuery, UriFormat.UriEscaped); + } + + public string ToUriForFinalmask() + { + var prefix = IsHttp ? "realm+http://" : "realm://"; + var uriBuilder = new StringBuilder(); + uriBuilder.Append(prefix); + uriBuilder.Append(Utils.UrlEncode(Token)); + uriBuilder.Append('@'); + uriBuilder.Append(RendezvousHostPort); + uriBuilder.Append('/'); + uriBuilder.Append(Utils.UrlEncode(RealmName)); + return uriBuilder.ToString(); + } +} diff --git a/v2rayN/ServiceLib/Models/Entities/ProtocolExtraItem.cs b/v2rayN/ServiceLib/Models/Entities/ProtocolExtraItem.cs index e9916e33..a0144ac7 100644 --- a/v2rayN/ServiceLib/Models/Entities/ProtocolExtraItem.cs +++ b/v2rayN/ServiceLib/Models/Entities/ProtocolExtraItem.cs @@ -31,6 +31,12 @@ public record ProtocolExtraItem public int? DownMbps { get; init; } public string? Ports { get; init; } public string? HopInterval { get; init; } + // realm://@[:port]/?stun=[:port]&stun=[:port]... + // example: + // realm://public@realm.hy2.io/57f9be7c-2810-4f5b-8cb9-260bc84d6c90?stun=example.stun:3478&stun=example2.stun:3478 + public string? Hy2RealmUrl { get; init; } + public string? GeckoMinPacketSize { get; init; } + public string? GeckoMaxPacketSize { get; init; } // naiveproxy public int? InsecureConcurrency { get; init; } diff --git a/v2rayN/ServiceLib/Resx/ResUI.Designer.cs b/v2rayN/ServiceLib/Resx/ResUI.Designer.cs index 47360c37..8ecf40e7 100644 --- a/v2rayN/ServiceLib/Resx/ResUI.Designer.cs +++ b/v2rayN/ServiceLib/Resx/ResUI.Designer.cs @@ -232,7 +232,7 @@ namespace ServiceLib.Resx { } /// - /// 查找类似 Fragment parameter format error. Please check Length (e.g. 50-100), Interval (1-100 range), and MaxSplit (0-10000). 的本地化字符串。 + /// 查找类似 Invalid range format. Use 'from-to' (e.g., 50-100). 的本地化字符串。 /// public static string FillFragmentParameterError { get { @@ -321,6 +321,15 @@ namespace ServiceLib.Resx { } } + /// + /// 查找类似 Invalid Realm URL. 的本地化字符串。 + /// + public static string InvalidHy2RealmUrl { + get { + return ResourceManager.GetString("InvalidHy2RealmUrl", resourceCulture); + } + } + /// /// 查找类似 Invalid address (URL) 的本地化字符串。 /// @@ -3186,6 +3195,15 @@ namespace ServiceLib.Resx { } } + /// + /// 查找类似 Gecko Packet Size (min/max) 的本地化字符串。 + /// + public static string TbGeckoPacketSize { + get { + return ResourceManager.GetString("TbGeckoPacketSize", resourceCulture); + } + } + /// /// 查找类似 Global Hotkey Settings 的本地化字符串。 /// @@ -3249,6 +3267,24 @@ namespace ServiceLib.Resx { } } + /// + /// 查找类似 Realm URL 的本地化字符串。 + /// + public static string TbHy2RealmUrl { + get { + return ResourceManager.GetString("TbHy2RealmUrl", resourceCulture); + } + } + + /// + /// 查找类似 Format: realm://<token>@<rendezvous-host>[:port]/<realm-name>?stun=<stun-host>[:port] 的本地化字符串。 + /// + public static string TbHy2RealmUrlTip { + get { + return ResourceManager.GetString("TbHy2RealmUrlTip", resourceCulture); + } + } + /// /// 查找类似 ICMP routing policy 的本地化字符串。 /// @@ -4095,114 +4131,6 @@ namespace ServiceLib.Resx { } } - /// - /// 查找类似 Fragment Length 的本地化字符串。 - /// - public static string TbSettingsFragmentLength { - get { - return ResourceManager.GetString("TbSettingsFragmentLength", resourceCulture); - } - } - - /// - /// 查找类似 Fragment size range in bytes (e.g., 50-100). First value must be <= second. Empty = default. 的本地化字符串。 - /// - public static string TbSettingsFragmentLengthTip { - get { - return ResourceManager.GetString("TbSettingsFragmentLengthTip", resourceCulture); - } - } - - /// - /// 查找类似 Fragment Interval 的本地化字符串。 - /// - public static string TbSettingsFragmentInterval { - get { - return ResourceManager.GetString("TbSettingsFragmentInterval", resourceCulture); - } - } - - /// - /// 查找类似 Delay between fragments in ms (e.g., 10-20). Range 1-100. First value must be <= second. Empty = default. 的本地化字符串。 - /// - public static string TbSettingsFragmentIntervalTip { - get { - return ResourceManager.GetString("TbSettingsFragmentIntervalTip", resourceCulture); - } - } - - /// - /// 查找类似 Max Split 的本地化字符串。 - /// - public static string TbSettingsFragmentMaxSplit { - get { - return ResourceManager.GetString("TbSettingsFragmentMaxSplit", resourceCulture); - } - } - - /// - /// 查找类似 Maximum number of splits (0 = unlimited, 0-10000). Only effective for Xray-core. Empty = default. 的本地化字符串。 - /// - public static string TbSettingsFragmentMaxSplitTip { - get { - return ResourceManager.GetString("TbSettingsFragmentMaxSplitTip", resourceCulture); - } - } - - /// - /// 查找类似 Fragment Packets 的本地化字符串。 - /// - public static string TbSettingsFragmentPackets { - get { - return ResourceManager.GetString("TbSettingsFragmentPackets", resourceCulture); - } - } - - /// - /// 查找类似 Packets to fragment: tlshello (TLS ClientHello) or 1-1 to 1-5 (first N TCP packets) 的本地化字符串。 - /// - public static string TbSettingsFragmentPacketsTip { - get { - return ResourceManager.GetString("TbSettingsFragmentPacketsTip", resourceCulture); - } - } - - /// - /// 查找类似 Sing-box Fragment Strategy 的本地化字符串。 - /// - public static string TbSettingsFragmentSingboxStrategy { - get { - return ResourceManager.GetString("TbSettingsFragmentSingboxStrategy", resourceCulture); - } - } - - /// - /// 查找类似 TLS Fragment: standard TLS record splitting. TCP Bypass: fragment TCP packets. Double: both modes combined. sing-box only. 的本地化字符串。 - /// - public static string TbSettingsFragmentSingboxStrategyTip { - get { - return ResourceManager.GetString("TbSettingsFragmentSingboxStrategyTip", resourceCulture); - } - } - - /// - /// 查找类似 Fallback Delay 的本地化字符串。 - /// - public static string TbSettingsFragmentFallbackDelay { - get { - return ResourceManager.GetString("TbSettingsFragmentFallbackDelay", resourceCulture); - } - } - - /// - /// 查找类似 Fallback delay for TLS fragment. Only effective for sing-box. Empty = default. 的本地化字符串。 - /// - public static string TbSettingsFragmentFallbackDelayTip { - get { - return ResourceManager.GetString("TbSettingsFragmentFallbackDelayTip", resourceCulture); - } - } - /// /// 查找类似 Enable hardware acceleration (requires restart) 的本地化字符串。 /// @@ -4257,6 +4185,96 @@ namespace ServiceLib.Resx { } } + /// + /// 查找类似 Fallback Delay 的本地化字符串。 + /// + public static string TbSettingsFragmentFallbackDelay { + get { + return ResourceManager.GetString("TbSettingsFragmentFallbackDelay", resourceCulture); + } + } + + /// + /// 查找类似 Fallback delay when ACK detection unavailable (e.g., 500ms). sing-box only. Empty = default. 的本地化字符串。 + /// + public static string TbSettingsFragmentFallbackDelayTip { + get { + return ResourceManager.GetString("TbSettingsFragmentFallbackDelayTip", resourceCulture); + } + } + + /// + /// 查找类似 Fragment Interval 的本地化字符串。 + /// + public static string TbSettingsFragmentInterval { + get { + return ResourceManager.GetString("TbSettingsFragmentInterval", resourceCulture); + } + } + + /// + /// 查找类似 Delay between fragments in ms (e.g., 10-20). Range 1-100. First value must be <= second. Empty = default. 的本地化字符串。 + /// + public static string TbSettingsFragmentIntervalTip { + get { + return ResourceManager.GetString("TbSettingsFragmentIntervalTip", resourceCulture); + } + } + + /// + /// 查找类似 Fragment Length 的本地化字符串。 + /// + public static string TbSettingsFragmentLength { + get { + return ResourceManager.GetString("TbSettingsFragmentLength", resourceCulture); + } + } + + /// + /// 查找类似 Fragment size range in bytes (e.g., 50-100). First value must be <= second. Empty = default. 的本地化字符串。 + /// + public static string TbSettingsFragmentLengthTip { + get { + return ResourceManager.GetString("TbSettingsFragmentLengthTip", resourceCulture); + } + } + + /// + /// 查找类似 Max Split 的本地化字符串。 + /// + public static string TbSettingsFragmentMaxSplit { + get { + return ResourceManager.GetString("TbSettingsFragmentMaxSplit", resourceCulture); + } + } + + /// + /// 查找类似 Maximum number of fragments per packet (0-10000, 0 = unlimited). Xray only. Empty = default. 的本地化字符串。 + /// + public static string TbSettingsFragmentMaxSplitTip { + get { + return ResourceManager.GetString("TbSettingsFragmentMaxSplitTip", resourceCulture); + } + } + + /// + /// 查找类似 Fragment Packets 的本地化字符串。 + /// + public static string TbSettingsFragmentPackets { + get { + return ResourceManager.GetString("TbSettingsFragmentPackets", resourceCulture); + } + } + + /// + /// 查找类似 Packets to fragment: tlshello (TLS ClientHello) or 1-1 to 1-5 (first N TCP packets) 的本地化字符串。 + /// + public static string TbSettingsFragmentPacketsTip { + get { + return ResourceManager.GetString("TbSettingsFragmentPacketsTip", resourceCulture); + } + } + /// /// 查找类似 Geo files source (optional) 的本地化字符串。 /// diff --git a/v2rayN/ServiceLib/Resx/ResUI.resx b/v2rayN/ServiceLib/Resx/ResUI.resx index e911aae0..6bea42e5 100644 --- a/v2rayN/ServiceLib/Resx/ResUI.resx +++ b/v2rayN/ServiceLib/Resx/ResUI.resx @@ -1812,4 +1812,16 @@ The "Get Certificate" action may fail if a self-signed certificate is used or if Insecure configuration detected: AllowInsecure is enabled but no certificate is provided. This may cause MITM attacks. + + Realm URL + + + Invalid Realm URL. + + + Format: realm://<token>@<rendezvous-host>[:port]/<realm-name>?stun=<stun-host>[:port] + + + Gecko Packet Size (min/max) + \ No newline at end of file diff --git a/v2rayN/ServiceLib/Resx/ResUI.zh-Hans.resx b/v2rayN/ServiceLib/Resx/ResUI.zh-Hans.resx index 4367efce..8aa676e1 100644 --- a/v2rayN/ServiceLib/Resx/ResUI.zh-Hans.resx +++ b/v2rayN/ServiceLib/Resx/ResUI.zh-Hans.resx @@ -1030,7 +1030,7 @@ 添加 [Hysteria2] - Hysteria 最大带宽 (Up/Dw) + Hysteria 最大带宽 (上传/下载) 使用系统 hosts @@ -1809,4 +1809,16 @@ 检测到不安全配置:AllowInsecure 已启用,但未提供证书。这可能会导致中间人攻击。 + + Realm URL + + + Realm URL 不正确。 + + + 格式:realm://<token>@<rendezvous-host>[:port]/<realm-name>?stun=<stun-host>[:port] + + + Gecko 包大小 (最小/最大) + \ No newline at end of file diff --git a/v2rayN/ServiceLib/Services/CoreConfig/Singbox/SingboxOutboundService.cs b/v2rayN/ServiceLib/Services/CoreConfig/Singbox/SingboxOutboundService.cs index 19171b32..9de5fa52 100644 --- a/v2rayN/ServiceLib/Services/CoreConfig/Singbox/SingboxOutboundService.cs +++ b/v2rayN/ServiceLib/Services/CoreConfig/Singbox/SingboxOutboundService.cs @@ -217,11 +217,17 @@ public partial class CoreConfigSingboxService if (!protocolExtra.SalamanderPass.IsNullOrEmpty()) { + var isGecko = !protocolExtra.GeckoMinPacketSize.IsNullOrEmpty() || !protocolExtra.GeckoMaxPacketSize.IsNullOrEmpty(); outbound.obfs = new() { - type = "salamander", + type = isGecko ? "gecko" : "salamander", password = protocolExtra.SalamanderPass.TrimEx(), }; + if (isGecko) + { + outbound.obfs.min_packet_size = protocolExtra.GeckoMinPacketSize.ToInt(); + outbound.obfs.max_packet_size = protocolExtra.GeckoMaxPacketSize.ToInt(); + } } int? upMbps = protocolExtra?.UpMbps is { } su and >= 0 ? su @@ -264,6 +270,21 @@ public partial class CoreConfigSingboxService } } + if (HyRealm.TryParse(protocolExtra.Hy2RealmUrl, out var realm) + && realm is not null) + { + var realm4Sbox = new HyRealm4Sbox() + { + server_url = realm.RendezvousHostPort, + token = realm.Token, + realm_id = realm.RealmName, + }; + outbound.realm = realm4Sbox; + outbound.server = null; + outbound.server_port = null; + outbound.server_ports = null; + } + break; } case EConfigType.TUIC: diff --git a/v2rayN/ServiceLib/Services/CoreConfig/V2ray/V2rayOutboundService.cs b/v2rayN/ServiceLib/Services/CoreConfig/V2ray/V2rayOutboundService.cs index b03f78fa..c5251f9c 100644 --- a/v2rayN/ServiceLib/Services/CoreConfig/V2ray/V2rayOutboundService.cs +++ b/v2rayN/ServiceLib/Services/CoreConfig/V2ray/V2rayOutboundService.cs @@ -607,16 +607,32 @@ public partial class CoreConfigV2rayService quicParams.congestion = "bbr"; } hy2Finalmask.quicParams = quicParams; + hy2Finalmask.udp ??= []; + if (HyRealm.TryParse(protocolExtra.Hy2RealmUrl, out var realm) + && realm is not null) + { + hy2Finalmask.udp.Add(new Mask4Ray + { + type = "realm", + settings = new MaskSettings4Ray { url = realm.ToUriForFinalmask(), stunServers = realm.StunList }, + }); + } if (!protocolExtra.SalamanderPass.IsNullOrEmpty()) { - hy2Finalmask.udp = - [ - new Mask4Ray - { - type = "salamander", - settings = new MaskSettings4Ray { password = protocolExtra.SalamanderPass.TrimEx(), } - } - ]; + var isGecko = !protocolExtra.GeckoMinPacketSize.IsNullOrEmpty() || !protocolExtra.GeckoMaxPacketSize.IsNullOrEmpty(); + var salamanderSettings = new MaskSettings4Ray + { + password = protocolExtra.SalamanderPass.TrimEx(), + }; + if (isGecko) + { + salamanderSettings.packetSize = $"{protocolExtra.GeckoMinPacketSize}-{protocolExtra.GeckoMaxPacketSize}"; + } + hy2Finalmask.udp.Add(new Mask4Ray + { + type = "salamander", + settings = salamanderSettings, + }); } streamSettings.hysteriaSettings = new() { diff --git a/v2rayN/ServiceLib/ViewModels/AddServerViewModel.cs b/v2rayN/ServiceLib/ViewModels/AddServerViewModel.cs index 338b3468..b7ef6c26 100644 --- a/v2rayN/ServiceLib/ViewModels/AddServerViewModel.cs +++ b/v2rayN/ServiceLib/ViewModels/AddServerViewModel.cs @@ -80,6 +80,15 @@ public class AddServerViewModel : MyReactiveObject [Reactive] public bool NaiveQuic { get; set; } + [Reactive] + public string Hy2RealmUrl { get; set; } + + [Reactive] + public int GeckoMinPacketSize { get; set; } + + [Reactive] + public int GeckoMaxPacketSize { get; set; } + [Reactive] public string RawHeaderType { get; set; } @@ -302,6 +311,9 @@ public class AddServerViewModel : MyReactiveObject CongestionControl = protocolExtra.CongestionControl ?? string.Empty; InsecureConcurrency = protocolExtra.InsecureConcurrency > 0 ? protocolExtra.InsecureConcurrency : null; NaiveQuic = protocolExtra.NaiveQuic ?? false; + Hy2RealmUrl = protocolExtra.Hy2RealmUrl ?? string.Empty; + GeckoMinPacketSize = protocolExtra.GeckoMinPacketSize.ToInt(); + GeckoMaxPacketSize = protocolExtra.GeckoMaxPacketSize.ToInt(); RawHeaderType = transport.RawHeaderType ?? Global.None; Host = transport.Host ?? string.Empty; @@ -357,6 +369,16 @@ public class AddServerViewModel : MyReactiveObject return; } } + HyRealm? realm = null; + if (!Hy2RealmUrl.IsNullOrEmpty()) + { + var realmResult = HyRealm.TryParse(Hy2RealmUrl, out realm); + if (!realmResult) + { + NoticeManager.Instance.Enqueue(ResUI.InvalidHy2RealmUrl); + return; + } + } SelectedSource.CoreType = CoreType.IsNullOrEmpty() ? null : Enum.Parse(CoreType); SelectedSource.AllowInsecure = AllowInsecure ? Global.StringTrue : Global.StringFalse; SelectedSource.MuxEnabled = MuxEnabled; @@ -403,6 +425,9 @@ public class AddServerViewModel : MyReactiveObject CongestionControl = CongestionControl.NullIfEmpty(), InsecureConcurrency = InsecureConcurrency > 0 ? InsecureConcurrency : null, NaiveQuic = NaiveQuic ? true : null, + Hy2RealmUrl = realm?.ToUri().NullIfEmpty(), + GeckoMinPacketSize = GeckoMinPacketSize > 0 ? GeckoMinPacketSize.ToString() : null, + GeckoMaxPacketSize = GeckoMaxPacketSize > 0 ? GeckoMaxPacketSize.ToString() : null, }); SelectedSource.SetTransportExtra(transport); diff --git a/v2rayN/v2rayN.Desktop/Views/AddServerWindow.axaml b/v2rayN/v2rayN.Desktop/Views/AddServerWindow.axaml index d3258d28..2f9c6b01 100644 --- a/v2rayN/v2rayN.Desktop/Views/AddServerWindow.axaml +++ b/v2rayN/v2rayN.Desktop/Views/AddServerWindow.axaml @@ -374,7 +374,7 @@ Grid.Row="2" ColumnDefinitions="300,Auto,Auto" IsVisible="False" - RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto"> + RowDefinitions="Auto,Auto,Auto,Auto,Auto"> - - - + Classes="IconButton"> + + + + + + + + + + + + + + + + + + + + + + + + + Text="{x:Static resx:ResUI.TbPorts7Tips}" + TextWrapping="Wrap" /> this.Bind(ViewModel, vm => vm.HopInterval, v => v.txtHopInt7.Text).DisposeWith(disposables); this.Bind(ViewModel, vm => vm.UpMbps, v => v.txtUpMbps7.Text).DisposeWith(disposables); this.Bind(ViewModel, vm => vm.DownMbps, v => v.txtDownMbps7.Text).DisposeWith(disposables); + this.Bind(ViewModel, vm => vm.Hy2RealmUrl, v => v.txtHy2RealmUrl.Text).DisposeWith(disposables); + this.Bind(ViewModel, vm => vm.GeckoMinPacketSize, v => v.txtMinGeckoPacketSize.Text).DisposeWith(disposables); + this.Bind(ViewModel, vm => vm.GeckoMaxPacketSize, v => v.txtMaxGeckoPacketSize.Text).DisposeWith(disposables); break; case EConfigType.TUIC: diff --git a/v2rayN/v2rayN/Views/AddServerWindow.xaml b/v2rayN/v2rayN/Views/AddServerWindow.xaml index 048e695c..6d506d8c 100644 --- a/v2rayN/v2rayN/Views/AddServerWindow.xaml +++ b/v2rayN/v2rayN/Views/AddServerWindow.xaml @@ -505,6 +505,7 @@ + @@ -526,21 +527,62 @@ Width="400" Margin="{StaticResource Margin4}" Style="{StaticResource DefTextBox}" /> - - - + StaysOpen="True" + Style="{StaticResource MaterialDesignToolForegroundPopupBox}"> + + + + + + + + + + + + + + Text="{x:Static resx:ResUI.TbPorts7Tips}" + TextWrapping="Wrap" /> vm.HopInterval, v => v.txtHopInt7.Text).DisposeWith(disposables); this.Bind(ViewModel, vm => vm.UpMbps, v => v.txtUpMbps7.Text).DisposeWith(disposables); this.Bind(ViewModel, vm => vm.DownMbps, v => v.txtDownMbps7.Text).DisposeWith(disposables); + this.Bind(ViewModel, vm => vm.Hy2RealmUrl, v => v.txtHy2RealmUrl.Text).DisposeWith(disposables); + this.Bind(ViewModel, vm => vm.GeckoMinPacketSize, v => v.txtMinGeckoPacketSize.Text).DisposeWith(disposables); + this.Bind(ViewModel, vm => vm.GeckoMaxPacketSize, v => v.txtMaxGeckoPacketSize.Text).DisposeWith(disposables); break; case EConfigType.TUIC: