diff --git a/v2rayN/ServiceLib.Tests/Fmt/InnerFmtTests.cs b/v2rayN/ServiceLib.Tests/Fmt/InnerFmtTests.cs new file mode 100644 index 00000000..91df67f4 --- /dev/null +++ b/v2rayN/ServiceLib.Tests/Fmt/InnerFmtTests.cs @@ -0,0 +1,37 @@ +using AwesomeAssertions; +using ServiceLib.Enums; +using ServiceLib.Handler.Fmt; +using ServiceLib.Tests.CoreConfig; +using Xunit; + +namespace ServiceLib.Tests.Fmt; + +public class InnerFmtTests +{ + [Fact] + public void ToUriAndResolve_ShouldRoundTripPolicyGroupReferences() + { + var childA = CoreConfigTestFactory.CreateSocksNode(ECoreType.Xray, "child-a", "child-a"); + var childB = CoreConfigTestFactory.CreateVmessNode(ECoreType.Xray, "child-b", "child-b"); + var group = CoreConfigTestFactory.CreatePolicyGroupNode(ECoreType.Xray, "group-1", "group-1", + [childA.IndexId, childB.IndexId]); + group.SetProtocolExtra(group.GetProtocolExtra() with { SubChildItems = "original-sub" }); + + var uri = InnerFmt.ToUri([group, childA, childB]); + + uri.Should().NotBeNullOrWhiteSpace(); + + var resolved = InnerFmt.Resolve(uri!, "sub-123"); + + resolved.Should().NotBeNull(); + resolved.Should().HaveCount(3); + + var resolvedGroup = resolved!.Single(x => x.Remarks == group.Remarks); + var resolvedChildA = resolved.Single(x => x.Remarks == childA.Remarks); + var resolvedChildB = resolved.Single(x => x.Remarks == childB.Remarks); + + resolvedGroup.ConfigType.Should().Be(EConfigType.PolicyGroup); + resolvedGroup.GetProtocolExtra().SubChildItems.Should().Be("sub-123"); + resolvedGroup.GetProtocolExtra().ChildItems.Should().Be($"{resolvedChildA.IndexId},{resolvedChildB.IndexId}"); + } +} diff --git a/v2rayN/ServiceLib/Global.cs b/v2rayN/ServiceLib/Global.cs index 4d4f541a..ca167fa3 100644 --- a/v2rayN/ServiceLib/Global.cs +++ b/v2rayN/ServiceLib/Global.cs @@ -64,6 +64,7 @@ public class Global public const string HttpsProtocol = "https://"; public const string SocksProtocol = "socks://"; public const string Socks5Protocol = "socks5://"; + public const string InnerUriProtocol = "v2rayn://"; public const string AsIs = "AsIs"; public const string IPIfNonMatch = "IPIfNonMatch"; public const string IPOnDemand = "IPOnDemand"; diff --git a/v2rayN/ServiceLib/Handler/ConfigHandler.cs b/v2rayN/ServiceLib/Handler/ConfigHandler.cs index c4c7b8e9..93d617a5 100644 --- a/v2rayN/ServiceLib/Handler/ConfigHandler.cs +++ b/v2rayN/ServiceLib/Handler/ConfigHandler.cs @@ -1767,6 +1767,55 @@ public static class ConfigHandler return -1; } + private static async Task AddBatchServers4InnerUri(Config config, string strData, string subid, bool isSub) + { + if (strData.IsNullOrEmpty()) + { + return -1; + } + + if (isSub && subid.IsNotEmpty()) + { + await RemoveServersViaSubid(config, subid, isSub); + } + + var lstServer = InnerFmt.Resolve(strData, subid); + if (lstServer?.Count > 0) + { + var counter = 0; + foreach (var profileItem in lstServer) + { + profileItem.Subid = subid; + profileItem.IsSub = isSub; + + var addStatus = profileItem.ConfigType switch + { + EConfigType.VMess => await AddVMessServer(config, profileItem), + EConfigType.Shadowsocks => await AddShadowsocksServer(config, profileItem), + EConfigType.HTTP => await AddHttpServer(config, profileItem), + EConfigType.SOCKS => await AddSocksServer(config, profileItem), + EConfigType.Trojan => await AddTrojanServer(config, profileItem), + EConfigType.VLESS => await AddVlessServer(config, profileItem), + EConfigType.Hysteria2 => await AddHysteria2Server(config, profileItem), + EConfigType.TUIC => await AddTuicServer(config, profileItem), + EConfigType.WireGuard => await AddWireguardServer(config, profileItem), + EConfigType.Anytls => await AddAnytlsServer(config, profileItem), + EConfigType.Naive => await AddNaiveServer(config, profileItem), + EConfigType.PolicyGroup or EConfigType.ProxyChain => await AddServerCommon(config, profileItem), + _ => -1, + }; + if (addStatus == 0) + { + counter++; + } + } + await SaveConfig(config); + return counter; + } + + return -1; + } + /// /// Main entry point for adding batch servers from various formats /// Tries different parsing methods to import as many servers as possible @@ -1815,6 +1864,20 @@ public static class ConfigHandler counter = await AddBatchServers4Wireguard(config, strData, subid, isSub); } + //May be standard uri mixed with internal uri + var innerUriCount = await AddBatchServers4InnerUri(config, strData, subid, isSub); + if (innerUriCount > 0) + { + if (counter > 0) + { + counter += innerUriCount; + } + else + { + counter = innerUriCount; + } + } + //maybe other sub if (counter < 1) { diff --git a/v2rayN/ServiceLib/Handler/Fmt/InnerFmt.cs b/v2rayN/ServiceLib/Handler/Fmt/InnerFmt.cs new file mode 100644 index 00000000..d818be67 --- /dev/null +++ b/v2rayN/ServiceLib/Handler/Fmt/InnerFmt.cs @@ -0,0 +1,299 @@ +namespace ServiceLib.Handler.Fmt; + +public class InnerFmt +{ + private static readonly Lazy SessionSalt = new(() => Utils.GetGuid(false)); + + public static List? Resolve(string strData, string subid) + { + var list = new List(); + // Overwrite externally imported indexIds to avoid possible sources of attacks + var indexIdMap = new Dictionary(); + using (var reader = new StringReader(strData)) + { + while (reader.ReadLine() is { } line) + { + if (line.IsNullOrEmpty()) + { + continue; + } + var trimmedLine = line.Trim(); + if (!line.StartsWith(Global.InnerUriProtocol, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + var profileItem = ResolveSingle(trimmedLine); + if (profileItem is null) + { + continue; + } + if (profileItem.ConfigType == EConfigType.Custom) + { + // Unsupported, also to avoid possible sources of attacks, skip it + continue; + } + // overwrite indexId + var newIndexId = Utils.GetGuid(false); + if (!profileItem.IndexId.IsNullOrEmpty()) + { + // Ignore duplicated indexId + indexIdMap[profileItem.IndexId] = newIndexId; + } + profileItem.IndexId = newIndexId; + list.Add(profileItem); + } + } + // For group-type profile items, also overwrite the ChildItems and ChildSubId + var emptyGroupProfileList = new List(); + foreach (var item in list.Where(i => i.ConfigType.IsGroupType())) + { + var protocolExtra = item.GetProtocolExtra(); + // Only allow "self" as a special value for SubChildItems to avoid possible sources of attacks, + // which means it will be replaced with the subid, otherwise set it to null + //if (!protocolExtra.SubChildItems.IsNullOrEmpty()) + if (protocolExtra.SubChildItems == "self") + { + protocolExtra = protocolExtra with + { + SubChildItems = subid + }; + } + else + { + protocolExtra = protocolExtra with + { + SubChildItems = null + }; + } + if (Utils.String2List(protocolExtra.ChildItems) is { Count: > 0 } childIndexIds) + { + var newChildIndexIds = childIndexIds + .Select(id => indexIdMap.GetValueOrDefault(id, null)) + .Where(id => !id.IsNullOrEmpty()) + .ToList(); + protocolExtra = protocolExtra with + { + ChildItems = Utils.List2String(newChildIndexIds) + }; + } + else + { + protocolExtra = protocolExtra with + { + ChildItems = null + }; + } + item.SetProtocolExtra(protocolExtra); + if (protocolExtra.SubChildItems.IsNullOrEmpty() + && protocolExtra.ChildItems.IsNullOrEmpty()) + { + emptyGroupProfileList.Add(item); + } + } + // Remove empty group profile items + list.RemoveAll(emptyGroupProfileList.Contains); + return list; + } + + public static string? ToUri(List items) + { + var sb = new StringBuilder(); + foreach (var item in items) + { + if (item.ConfigType == EConfigType.Custom) + { + continue; + } + var itemClone = JsonUtils.DeepCopy(item); + if (itemClone is null) + { + continue; + } + // overwrite indexId + var originalIndexId = itemClone.IndexId; + var newIndexId = GetReproducibleExportId(originalIndexId); + itemClone.IndexId = newIndexId; + if (itemClone.ConfigType.IsGroupType()) + { + var protocolExtra = itemClone.GetProtocolExtra(); + if (!protocolExtra.SubChildItems.IsNullOrEmpty()) + { + protocolExtra = protocolExtra with + { + SubChildItems = "self" + }; + } + if (Utils.String2List(protocolExtra.ChildItems) is { Count: > 0 } childIndexIds) + { + var newChildIndexIds = childIndexIds + .Select(GetReproducibleExportId) + .Where(id => !id.IsNullOrEmpty()) + .ToList(); + protocolExtra = protocolExtra with + { + ChildItems = Utils.List2String(newChildIndexIds) + }; + } + itemClone.SetProtocolExtra(protocolExtra); + } + var uri = ToUriSingle(itemClone); + if (!uri.IsNullOrEmpty()) + { + sb.AppendLine(uri); + } + } + return sb.Length > 0 ? sb.ToString() : null; + } + + private static ProfileItem? ResolveSingle(string str) + { + // format: v2rayn://vless/{url-safe base64 encoded_string} + var parsedUri = Utils.TryUri(str); + if (parsedUri is null) + { + return null; + } + var segment = parsedUri.AbsolutePath.TrimStart('/'); + var decodedResult = Utils.Base64Decode(segment); + var jsonNode = JsonUtils.ParseJson(decodedResult); + if (jsonNode is not JsonObject jsonObj) + { + return null; + } + // flatten + // move jsonObj.ProtoExtraObj to jsonObj.ProtoExtra (string) + // move jsonObj.TransportExtraObj to jsonObj.TransportExtra (string) + if (jsonObj.TryGetPropertyValue("ProtoExtraObj", out var protoExtraNode) + && protoExtraNode is JsonObject protoExtraObj) + { + jsonObj["ProtoExtra"] = JsonUtils.Serialize(protoExtraObj, false); + jsonObj.Remove("ProtoExtraObj"); + } + if (jsonObj.TryGetPropertyValue("TransportExtraObj", out var transportExtraNode) + && transportExtraNode is JsonObject transportExtraObj) + { + jsonObj["TransportExtra"] = JsonUtils.Serialize(transportExtraObj, false); + jsonObj.Remove("TransportExtraObj"); + } + var profileItem = JsonUtils.Deserialize(JsonUtils.Serialize(jsonObj, false)); + if (profileItem is null) + { + return null; + } + if (profileItem.ConfigVersion != 4) + { + return null; + } + // Check Enum.IsDefined + if (!Enum.IsDefined(typeof(EConfigType), profileItem.ConfigType)) + { + return null; + } + if (profileItem.CoreType is not (null or ECoreType.Xray or ECoreType.sing_box)) + { + return null; + } + var protocolExtra = profileItem.GetProtocolExtra(); + var multipleLoad = protocolExtra.MultipleLoad; + if (multipleLoad is not null && !Enum.IsDefined(typeof(EMultipleLoad), multipleLoad)) + { + return null; + } + return profileItem; + } + + private static string? ToUriSingle(ProfileItem item) + { + var jsonNode = JsonUtils.ParseJson(JsonUtils.Serialize(item, false)); + if (jsonNode is not JsonObject jsonObj) + { + return null; + } + // unflatten + // move jsonObj.ProtoExtra (string) to jsonObj.ProtoExtraObj + // move jsonObj.TransportExtra (string) to jsonObj.TransportExtraObj + if (jsonObj.TryGetPropertyValue("ProtoExtra", out var protoExtraNode) + && protoExtraNode is JsonValue protoExtraValue + && protoExtraValue.TryGetValue(out var protoExtraStr) + && !protoExtraStr.IsNullOrEmpty() + && JsonUtils.ParseJson(protoExtraStr) is JsonObject protoExtraObj) + { + jsonObj["ProtoExtraObj"] = protoExtraObj; + jsonObj.Remove("ProtoExtra"); + } + if (jsonObj.TryGetPropertyValue("TransportExtra", out var transportExtraNode) + && transportExtraNode is JsonValue transportExtraValue + && transportExtraValue.TryGetValue(out var transportExtraStr) + && !transportExtraStr.IsNullOrEmpty() + && JsonUtils.ParseJson(transportExtraStr) is JsonObject transportExtraObj) + { + jsonObj["TransportExtraObj"] = transportExtraObj; + jsonObj.Remove("TransportExtra"); + } + // Remove empty properties to reduce the length of the exported string + RemoveEmptyJson(jsonObj); + var jsonStr = JsonUtils.Serialize(jsonObj, false); + var encodedStr = Utils.Base64Encode(jsonStr).Replace('+', '-').Replace('/', '_').Replace("=", ""); + return $"{Global.InnerUriProtocol}{item.ConfigType.ToString().ToLower()}/{encodedStr}"; + } + + private static string GetReproducibleExportId(string originalIndexId) + { + if (originalIndexId.IsNullOrEmpty()) + { + return originalIndexId; + } + + var hash = HashCode.Combine(SessionSalt.Value, originalIndexId) & 0x7FFFFFFF; + var bytes = BitConverter.GetBytes(hash); + return Convert.ToBase64String(bytes).Replace("=", ""); + } + + private static void RemoveEmptyJson(JsonNode? node) + { + // ReSharper disable once ConvertIfStatementToSwitchStatement + if (node is JsonObject jsonObject) + { + var propertiesToRemove = new List(); + + foreach (var property in jsonObject) + { + RemoveEmptyJson(property.Value); + + if (IsEmpty(property.Value)) + { + propertiesToRemove.Add(property.Key); + } + } + + foreach (var key in propertiesToRemove) + { + jsonObject.Remove(key); + } + } + else if (node is JsonArray jsonArray) + { + for (var i = jsonArray.Count - 1; i >= 0; i--) + { + RemoveEmptyJson(jsonArray[i]); + + if (IsEmpty(jsonArray[i])) + { + jsonArray.RemoveAt(i); + } + } + } + } + + private static bool IsEmpty(JsonNode? node) + { + return node switch + { + null => true, + JsonValue value when value.TryGetValue(out var str) => string.IsNullOrEmpty(str), + JsonObject obj => obj.Count == 0, + JsonArray arr => arr.Count == 0, + _ => false + }; + } +} diff --git a/v2rayN/ServiceLib/Resx/ResUI.Designer.cs b/v2rayN/ServiceLib/Resx/ResUI.Designer.cs index 81efe4a5..7aba3abe 100644 --- a/v2rayN/ServiceLib/Resx/ResUI.Designer.cs +++ b/v2rayN/ServiceLib/Resx/ResUI.Designer.cs @@ -1023,6 +1023,15 @@ namespace ServiceLib.Resx { } } + /// + /// 查找类似 Export v2rayN Internal Share Link to Clipboard 的本地化字符串。 + /// + public static string menuExport2InnerUri { + get { + return ResourceManager.GetString("menuExport2InnerUri", resourceCulture); + } + } + /// /// 查找类似 Export Share Link to Clipboard 的本地化字符串。 /// diff --git a/v2rayN/ServiceLib/Resx/ResUI.fa-Ir.resx b/v2rayN/ServiceLib/Resx/ResUI.fa-Ir.resx index e21d3fec..21a6c7f1 100644 --- a/v2rayN/ServiceLib/Resx/ResUI.fa-Ir.resx +++ b/v2rayN/ServiceLib/Resx/ResUI.fa-Ir.resx @@ -1728,4 +1728,7 @@ The "Get Certificate" action may fail if a self-signed certificate is used or if PreSharedKey + + Export v2rayN Internal Share Link to Clipboard + \ No newline at end of file diff --git a/v2rayN/ServiceLib/Resx/ResUI.fr.resx b/v2rayN/ServiceLib/Resx/ResUI.fr.resx index 898acc2a..bca6c2b7 100644 --- a/v2rayN/ServiceLib/Resx/ResUI.fr.resx +++ b/v2rayN/ServiceLib/Resx/ResUI.fr.resx @@ -1725,4 +1725,7 @@ The "Get Certificate" action may fail if a self-signed certificate is used or if PreSharedKey + + Export v2rayN Internal Share Link to Clipboard + \ No newline at end of file diff --git a/v2rayN/ServiceLib/Resx/ResUI.hu.resx b/v2rayN/ServiceLib/Resx/ResUI.hu.resx index 4eb6fc35..d73a170c 100644 --- a/v2rayN/ServiceLib/Resx/ResUI.hu.resx +++ b/v2rayN/ServiceLib/Resx/ResUI.hu.resx @@ -1728,4 +1728,7 @@ The "Get Certificate" action may fail if a self-signed certificate is used or if PreSharedKey + + Export v2rayN Internal Share Link to Clipboard + \ No newline at end of file diff --git a/v2rayN/ServiceLib/Resx/ResUI.resx b/v2rayN/ServiceLib/Resx/ResUI.resx index b5d832c6..13883be4 100644 --- a/v2rayN/ServiceLib/Resx/ResUI.resx +++ b/v2rayN/ServiceLib/Resx/ResUI.resx @@ -1728,4 +1728,7 @@ The "Get Certificate" action may fail if a self-signed certificate is used or if PreSharedKey + + Export v2rayN Internal Share Link to Clipboard + \ No newline at end of file diff --git a/v2rayN/ServiceLib/Resx/ResUI.ru.resx b/v2rayN/ServiceLib/Resx/ResUI.ru.resx index fa9bbce1..4d02c0a7 100644 --- a/v2rayN/ServiceLib/Resx/ResUI.ru.resx +++ b/v2rayN/ServiceLib/Resx/ResUI.ru.resx @@ -1728,4 +1728,7 @@ PreSharedKey + + Export v2rayN Internal Share Link to Clipboard + \ 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 c6a6378b..e8617160 100644 --- a/v2rayN/ServiceLib/Resx/ResUI.zh-Hans.resx +++ b/v2rayN/ServiceLib/Resx/ResUI.zh-Hans.resx @@ -1725,4 +1725,7 @@ PreSharedKey + + 导出 v2rayN 内部分享链接至剪贴板 (多选) + \ No newline at end of file diff --git a/v2rayN/ServiceLib/Resx/ResUI.zh-Hant.resx b/v2rayN/ServiceLib/Resx/ResUI.zh-Hant.resx index 821485e8..547f5749 100644 --- a/v2rayN/ServiceLib/Resx/ResUI.zh-Hant.resx +++ b/v2rayN/ServiceLib/Resx/ResUI.zh-Hant.resx @@ -1725,4 +1725,7 @@ PreSharedKey + + Export v2rayN Internal Share Link to Clipboard + \ No newline at end of file diff --git a/v2rayN/ServiceLib/ViewModels/ProfilesViewModel.cs b/v2rayN/ServiceLib/ViewModels/ProfilesViewModel.cs index 88e8b679..609ae0d1 100644 --- a/v2rayN/ServiceLib/ViewModels/ProfilesViewModel.cs +++ b/v2rayN/ServiceLib/ViewModels/ProfilesViewModel.cs @@ -72,6 +72,7 @@ public class ProfilesViewModel : MyReactiveObject public ReactiveCommand Export2ClientConfigClipboardCmd { get; } public ReactiveCommand Export2ShareUrlCmd { get; } public ReactiveCommand Export2ShareUrlBase64Cmd { get; } + public ReactiveCommand Export2InnerUriCmd { get; } public ReactiveCommand AddSubCmd { get; } public ReactiveCommand EditSubCmd { get; } @@ -212,6 +213,10 @@ public class ProfilesViewModel : MyReactiveObject { await Export2ShareUrlAsync(true); }, canEditRemove); + Export2InnerUriCmd = ReactiveCommand.CreateFromTask(async () => + { + await Export2InnerUrlAsync(); + }, canEditRemove); //Subscription AddSubCmd = ReactiveCommand.CreateFromTask(async () => @@ -840,6 +845,32 @@ public class ProfilesViewModel : MyReactiveObject } } + public async Task Export2InnerUrlAsync() + { + var lstSelected = await GetProfileItems(true); + if (lstSelected == null) + { + return; + } + + var result = string.Empty; + + await Task.Run(() => + { + result = InnerFmt.ToUri(lstSelected); + }); + + if (!result.IsNullOrEmpty()) + { + await _updateView?.Invoke(EViewAction.SetClipboardData, result); + NoticeManager.Instance.SendMessage(ResUI.BatchExportURLSuccessfully); + } + else + { + NoticeManager.Instance.Enqueue(ResUI.OperationFailed); + } + } + #endregion Add Servers #region Subscription diff --git a/v2rayN/v2rayN.Desktop/Views/ProfilesView.axaml b/v2rayN/v2rayN.Desktop/Views/ProfilesView.axaml index 42fab7c2..a567ae6a 100644 --- a/v2rayN/v2rayN.Desktop/Views/ProfilesView.axaml +++ b/v2rayN/v2rayN.Desktop/Views/ProfilesView.axaml @@ -192,6 +192,7 @@ Header="{x:Static resx:ResUI.menuExport2ShareUrl}" InputGesture="Ctrl+C" /> + diff --git a/v2rayN/v2rayN.Desktop/Views/ProfilesView.axaml.cs b/v2rayN/v2rayN.Desktop/Views/ProfilesView.axaml.cs index cca3378f..b025c114 100644 --- a/v2rayN/v2rayN.Desktop/Views/ProfilesView.axaml.cs +++ b/v2rayN/v2rayN.Desktop/Views/ProfilesView.axaml.cs @@ -88,6 +88,7 @@ public partial class ProfilesView : ReactiveUserControl this.BindCommand(ViewModel, vm => vm.Export2ClientConfigClipboardCmd, v => v.menuExport2ClientConfigClipboard).DisposeWith(disposables); this.BindCommand(ViewModel, vm => vm.Export2ShareUrlCmd, v => v.menuExport2ShareUrl).DisposeWith(disposables); this.BindCommand(ViewModel, vm => vm.Export2ShareUrlBase64Cmd, v => v.menuExport2ShareUrlBase64).DisposeWith(disposables); + this.BindCommand(ViewModel, vm => vm.Export2InnerUriCmd, v => v.menuExport2InnerUri).DisposeWith(disposables); AppEvents.AppExitRequested .AsObservable() diff --git a/v2rayN/v2rayN/Views/ProfilesView.xaml b/v2rayN/v2rayN/Views/ProfilesView.xaml index 97d3af3f..5a25e4c7 100644 --- a/v2rayN/v2rayN/Views/ProfilesView.xaml +++ b/v2rayN/v2rayN/Views/ProfilesView.xaml @@ -242,6 +242,10 @@ x:Name="menuExport2ShareUrlBase64" Height="{StaticResource MenuItemHeight}" Header="{x:Static resx:ResUI.menuExport2ShareUrlBase64}" /> + diff --git a/v2rayN/v2rayN/Views/ProfilesView.xaml.cs b/v2rayN/v2rayN/Views/ProfilesView.xaml.cs index 4445194d..1db64f34 100644 --- a/v2rayN/v2rayN/Views/ProfilesView.xaml.cs +++ b/v2rayN/v2rayN/Views/ProfilesView.xaml.cs @@ -82,6 +82,7 @@ public partial class ProfilesView this.BindCommand(ViewModel, vm => vm.Export2ClientConfigClipboardCmd, v => v.menuExport2ClientConfigClipboard).DisposeWith(disposables); this.BindCommand(ViewModel, vm => vm.Export2ShareUrlCmd, v => v.menuExport2ShareUrl).DisposeWith(disposables); this.BindCommand(ViewModel, vm => vm.Export2ShareUrlBase64Cmd, v => v.menuExport2ShareUrlBase64).DisposeWith(disposables); + this.BindCommand(ViewModel, vm => vm.Export2InnerUriCmd, v => v.menuExport2InnerUri).DisposeWith(disposables); AppEvents.AppExitRequested .AsObservable()