diff --git a/v2rayN/ServiceLib.Tests/CoreConfig/CoreConfigTestFactory.cs b/v2rayN/ServiceLib.Tests/CoreConfig/CoreConfigTestFactory.cs index 0c6e9bae..4109ea6e 100644 --- a/v2rayN/ServiceLib.Tests/CoreConfig/CoreConfigTestFactory.cs +++ b/v2rayN/ServiceLib.Tests/CoreConfig/CoreConfigTestFactory.cs @@ -150,6 +150,23 @@ internal static class CoreConfigTestFactory }; } + public static ProfileItem CreateCustomOutboundNode(ECoreType coreType, string indexId = "node-custom-1", + string remarks = "demo-custom-outbound", string address = "custom_outbound.json") + { + return new ProfileItem + { + IndexId = indexId, + ConfigType = EConfigType.Outbound, + CoreType = coreType, + Remarks = remarks, + Address = address, + Port = 0, + Network = nameof(ETransport.raw), + StreamSecurity = string.Empty, + Subid = string.Empty, + }; + } + public static ProfileItem CreatePolicyGroupNode(ECoreType coreType, string indexId, string remarks, IEnumerable childIndexIds) { diff --git a/v2rayN/ServiceLib.Tests/CoreConfig/Singbox/CoreConfigSingboxServiceTests.cs b/v2rayN/ServiceLib.Tests/CoreConfig/Singbox/CoreConfigSingboxServiceTests.cs index b07bef28..1b19e2c3 100644 --- a/v2rayN/ServiceLib.Tests/CoreConfig/Singbox/CoreConfigSingboxServiceTests.cs +++ b/v2rayN/ServiceLib.Tests/CoreConfig/Singbox/CoreConfigSingboxServiceTests.cs @@ -626,71 +626,40 @@ public class CoreConfigSingboxServiceTests } } - [Fact] - public void GenerateClientConfigContent_TunEnabled_ShouldKeepEmbeddedTunRules() - { - // The embedded tun rules reject local-network noise (NetBIOS/mDNS, multicast). - // They are deserialized into List, so a schema mismatch in the - // embedded template makes JsonUtils.Deserialize return null and silently - // drops every one of them. - var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box); - config.TunModeItem.EnableTun = true; - CoreConfigTestFactory.BindAppManagerConfig(config); - - var node = CoreConfigTestFactory.CreateVmessNode(ECoreType.sing_box); - var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.sing_box) with - { - IsTunEnabled = true, - }; - - var result = new CoreConfigSingboxService(context).GenerateClientConfigContent(); - - result.Success.Should().BeTrue($"ret msg: {result.Msg}"); - var cfg = JsonUtils.Deserialize(result.Data!.ToString())!; - - cfg.route.rules.Should().Contain( - r => r.action == "reject" - && r.network != null && r.network.Contains("udp") - && r.port != null && r.port.Contains(5353), - "the embedded tun rules must reject mDNS/NetBIOS noise"); - cfg.route.rules.Should().Contain( - r => r.action == "reject" - && r.ip_cidr != null && r.ip_cidr.Contains("224.0.0.0/3"), - "the embedded tun rules must reject multicast traffic"); - } [Fact] - public void GenerateClientConfigContent_TunEnabled_ShouldRejectTrafficToTunOwnAddresses() + public void GenerateClientConfigContent_CustomOutbound_ShouldReplaceWithUserCustomOutboundJson() { - // Regression test: traffic addressed to the TUN interface's own addresses must - // never reach an outbound. auto_route hijacks the default route, so `direct` - // writes such a packet straight back into the TUN, which routes it to the - // outbound again - an infinite loop that pins a CPU core. Observed in the wild - // with WebRTC ICE connectivity checks against the TUN's own fc00::/7 ULA - // address, sustaining ~8k packets/s out of the TUN interface. var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box); - config.TunModeItem.EnableTun = true; - config.TunModeItem.EnableIPv6Address = true; CoreConfigTestFactory.BindAppManagerConfig(config); - var node = CoreConfigTestFactory.CreateVmessNode(ECoreType.sing_box); - var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.sing_box) with + var customNode = CoreConfigTestFactory.CreateCustomOutboundNode(ECoreType.sing_box, "n-custom", "custom-singbox"); + var customJsonContent = """ { - IsTunEnabled = true, - }; - - var result = new CoreConfigSingboxService(context).GenerateClientConfigContent(); - - result.Success.Should().BeTrue($"ret msg: {result.Msg}"); - var cfg = JsonUtils.Deserialize(result.Data!.ToString())!; - var tun = cfg.inbounds.First(i => i.type == "tun"); - tun.address.Should().NotBeNullOrEmpty(); - - foreach (var address in tun.address!) - { - cfg.route.rules.Should().Contain( - r => r.action == "reject" && r.ip_cidr != null && r.ip_cidr.Contains(address), - $"traffic to the TUN's own address '{address}' must be rejected, not routed"); + "type": "shadowsocks", + "server": "1.2.3.4", + "server_port": 8388, + "method": "aes-128-gcm", + "password": "custom_password" } + """; + + var context = CoreConfigTestFactory.CreateContext(config, customNode, ECoreType.sing_box); + context.CustomOutboundContent[customNode.IndexId] = customJsonContent; + + var result = new CoreConfigSingboxService(context).GenerateClientConfigContent(); + + result.Success.Should().BeTrue($"ret msg: {result.Msg}"); + result.Data.Should().NotBeNull(); + + var cfg = JsonUtils.Deserialize(result.Data!.ToString()); + cfg.Should().NotBeNull(); + var proxyOutbound = cfg!.outbounds.FirstOrDefault(o => o.tag == Global.ProxyTag); + proxyOutbound.Should().NotBeNull(); + proxyOutbound!.type.Should().Be("shadowsocks"); + proxyOutbound.server.Should().Be("1.2.3.4"); + proxyOutbound.server_port.Should().Be(8388); + proxyOutbound.method.Should().Be("aes-128-gcm"); + proxyOutbound.password.Should().Be("custom_password"); } } diff --git a/v2rayN/ServiceLib.Tests/CoreConfig/V2ray/CoreConfigV2rayServiceTests.cs b/v2rayN/ServiceLib.Tests/CoreConfig/V2ray/CoreConfigV2rayServiceTests.cs index 983aaf96..73f3218e 100644 --- a/v2rayN/ServiceLib.Tests/CoreConfig/V2ray/CoreConfigV2rayServiceTests.cs +++ b/v2rayN/ServiceLib.Tests/CoreConfig/V2ray/CoreConfigV2rayServiceTests.cs @@ -592,4 +592,43 @@ public class CoreConfigV2rayServiceTests tunInbound!.settings.autoSystemRoutingTable.Should().Contain("10.0.0.0/32"); tunInbound!.settings.autoSystemRoutingTable.Should().Contain("10.0.0.2/31"); } + + [Fact] + public void GenerateClientConfigContent_CustomOutbound_ShouldReplaceWithUserCustomOutboundJson() + { + var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray); + CoreConfigTestFactory.BindAppManagerConfig(config); + + var customNode = CoreConfigTestFactory.CreateCustomOutboundNode(ECoreType.Xray, "n-custom", "custom-xray"); + var customJsonContent = """ + { + "protocol": "shadowsocks", + "settings": { + "servers": [ + { + "address": "1.2.3.4", + "port": 8388, + "method": "aes-128-gcm", + "password": "custom_password" + } + ] + } + } + """; + + var context = CoreConfigTestFactory.CreateContext(config, customNode, ECoreType.Xray); + context.CustomOutboundContent[customNode.IndexId] = customJsonContent; + + var result = new CoreConfigV2rayService(context).GenerateClientConfigContent(); + + result.Success.Should().BeTrue($"ret msg: {result.Msg}"); + result.Data.Should().NotBeNull(); + + var cfg = JsonUtils.Deserialize(result.Data!.ToString()); + cfg.Should().NotBeNull(); + var proxyOutbound = cfg!.outbounds.FirstOrDefault(o => o.tag == Global.ProxyTag); + proxyOutbound.Should().NotBeNull(); + proxyOutbound!.protocol.Should().Be("shadowsocks"); + proxyOutbound.settings.servers.Should().NotBeNull(); + } } diff --git a/v2rayN/ServiceLib/Common/Extension.cs b/v2rayN/ServiceLib/Common/Extension.cs index e3ed703d..c9e46d31 100644 --- a/v2rayN/ServiceLib/Common/Extension.cs +++ b/v2rayN/ServiceLib/Common/Extension.cs @@ -92,7 +92,7 @@ public static class Extension public static bool IsComplexType(this EConfigType configType) { - return configType is EConfigType.Custom or EConfigType.PolicyGroup or EConfigType.ProxyChain; + return configType is EConfigType.Custom or EConfigType.Outbound or EConfigType.PolicyGroup or EConfigType.ProxyChain; } /// diff --git a/v2rayN/ServiceLib/Enums/EConfigType.cs b/v2rayN/ServiceLib/Enums/EConfigType.cs index ae4e30ca..78723c07 100644 --- a/v2rayN/ServiceLib/Enums/EConfigType.cs +++ b/v2rayN/ServiceLib/Enums/EConfigType.cs @@ -14,6 +14,7 @@ public enum EConfigType HTTP = 10, Anytls = 11, Naive = 12, + Outbound = 13, PolicyGroup = 101, ProxyChain = 102, } diff --git a/v2rayN/ServiceLib/Handler/Builder/CoreConfigContextBuilder.cs b/v2rayN/ServiceLib/Handler/Builder/CoreConfigContextBuilder.cs index 839ed6e5..49fd474f 100644 --- a/v2rayN/ServiceLib/Handler/Builder/CoreConfigContextBuilder.cs +++ b/v2rayN/ServiceLib/Handler/Builder/CoreConfigContextBuilder.cs @@ -1,3 +1,5 @@ +using System.ComponentModel.DataAnnotations; + namespace ServiceLib.Handler.Builder; public record CoreConfigContextBuilderResult(CoreConfigContext Context, NodeValidatorResult ValidatorResult) @@ -95,6 +97,7 @@ public class CoreConfigContextBuilder context.AllProxiesMap[$"remark:{ruleItem.OutboundTag}"] = actRuleNode; } } + if (context.IsTunEnabled && context.AppConfig.TunModeItem.RouteExcludeAddress is { Count: > 0 }) { var appConfig = JsonUtils.DeepCopy(config); @@ -322,14 +325,14 @@ public class CoreConfigContextBuilder { return await RegisterGroupNodeAsync(context, node); } - return RegisterSingleNodeAsync(context, node); + return await RegisterSingleNodeAsync(context, node); } /// /// Validates a single (non-group) node and, on success, adds it to the proxy map /// and records any domain addresses that should bypass the proxy. /// - private static NodeValidatorResult RegisterSingleNodeAsync(CoreConfigContext context, ProfileItem node) + private static async Task RegisterSingleNodeAsync(CoreConfigContext context, ProfileItem node) { if (node.ConfigType.IsGroupType()) { @@ -337,6 +340,29 @@ public class CoreConfigContextBuilder } var nodeValidatorResult = NodeValidator.Validate(node, context.RunCoreType); + + if (node.ConfigType == EConfigType.Outbound) + { + var addressFileName = node.Address; + if (!File.Exists(addressFileName)) + { + addressFileName = Utils.GetConfigPath(addressFileName); + } + if (!File.Exists(addressFileName)) + { + nodeValidatorResult.Errors.Add(string.Format(ResUI.MsgCustomOutboundFileNotFound, node.Remarks, addressFileName)); + } + try + { + var fileContent = await File.ReadAllTextAsync(addressFileName); + context.CustomOutboundContent[node.IndexId] = fileContent; + } + catch + { + nodeValidatorResult.Errors.Add(string.Format(ResUI.MsgCustomOutboundFileNotFound, node.Remarks, addressFileName)); + } + } + var msgs = new List([.. nodeValidatorResult.Errors, .. nodeValidatorResult.Warnings]); if (msgs.Count > 0) { @@ -438,7 +464,7 @@ public class CoreConfigContextBuilder if (!childNode.ConfigType.IsGroupType()) { - var childNodeResult = RegisterSingleNodeAsync(context, childNode); + var childNodeResult = await RegisterSingleNodeAsync(context, childNode); childNodeValidatorResult.Warnings.AddRange(childNodeResult.Warnings.Select(w => string.Format(ResUI.MsgGroupChildNodeWarning, node.Remarks, childNode.Remarks, w))); childNodeValidatorResult.Errors.AddRange(childNodeResult.Errors.Select(e => diff --git a/v2rayN/ServiceLib/Handler/Builder/NodeValidator.cs b/v2rayN/ServiceLib/Handler/Builder/NodeValidator.cs index ad082161..cf0dacf0 100644 --- a/v2rayN/ServiceLib/Handler/Builder/NodeValidator.cs +++ b/v2rayN/ServiceLib/Handler/Builder/NodeValidator.cs @@ -36,6 +36,15 @@ public class NodeValidator return; } + if (item.ConfigType is EConfigType.Outbound) + { + if (item.CoreType != coreType) + { + v.Error(string.Format(ResUI.MsgCoreNotSupportProtocol, coreType.ToString(), item.ConfigType)); + } + return; + } + if (item.ConfigType.IsGroupType()) { // Group logic is handled in ValidateGroupNode diff --git a/v2rayN/ServiceLib/Handler/ConfigHandler.cs b/v2rayN/ServiceLib/Handler/ConfigHandler.cs index 5b220058..4a774a2e 100644 --- a/v2rayN/ServiceLib/Handler/ConfigHandler.cs +++ b/v2rayN/ServiceLib/Handler/ConfigHandler.cs @@ -382,6 +382,13 @@ public static class ConfigHandler { } } + else if (profileItem.ConfigType == EConfigType.Outbound) + { + profileItem.Address = Utils.GetConfigPath(profileItem.Address); + if (await AddCustomOutboundServer(config, profileItem, false) == 0) + { + } + } else { await AddServerCommon(config, profileItem, true); @@ -579,6 +586,44 @@ public static class ConfigHandler return 0; } + + public static async Task AddCustomOutboundServer(Config config, ProfileItem profileItem, bool blDelete, bool toFile = true) + { + var fileName = profileItem.Address; + if (!File.Exists(fileName)) + { + return -1; + } + var ext = Path.GetExtension(fileName); + var newFileName = $"{Utils.GetGuid()}{ext}"; + //newFileName = Path.Combine(Utile.GetTempPath(), newFileName); + + try + { + File.Copy(fileName, Utils.GetConfigPath(newFileName)); + if (blDelete) + { + File.Delete(fileName); + } + } + catch (Exception ex) + { + Logging.SaveLog(_tag, ex); + return -1; + } + + profileItem.Address = newFileName; + profileItem.ConfigType = EConfigType.Outbound; + if (profileItem.Remarks.IsNullOrEmpty()) + { + profileItem.Remarks = $"import custom outbound@{DateTime.Now.ToString("yyyy/MM/dd HH:mm:ss")}"; + } + + await AddServerCommon(config, profileItem, toFile); + + return 0; + } + /// /// Edit an existing custom server configuration /// Updates the server's properties without changing the file @@ -600,6 +645,8 @@ public static class ConfigHandler item.CoreType = profileItem.CoreType; item.DisplayLog = profileItem.DisplayLog; item.PreSocksPort = profileItem.PreSocksPort; + + item.ProtoExtra = profileItem.ProtoExtra; } if (await SQLiteHelper.Instance.UpdateAsync(item) > 0) @@ -1479,7 +1526,7 @@ public static class ConfigHandler var matchedChildProfiles = childProfiles?.Where(p => p != null && p.IsValid() && - !p.ConfigType.IsComplexType() && + (!p.ConfigType.IsComplexType() || p.ConfigType == EConfigType.Outbound) && (extraItem.Filter.IsNullOrEmpty() || Regex.IsMatch(p.Remarks, extraItem.Filter)) ) .ToList() ?? []; @@ -1675,68 +1722,179 @@ public static class ConfigHandler } var subItem = await AppManager.Instance.GetSubItem(subid); + + if (subItem?.CustomCoreType is null) + { + return await AddBatchServersDefaultCustom(config, strData, subid, isSub, subItem); + } + + return await AddBatchServersSpecificCustom(config, strData, subid, isSub, subItem); + } + + private static async Task AddBatchServersDefaultCustom( + Config config, + string strData, + string subid, + bool isSub, + SubItem? subItem) + { var subRemarks = subItem?.Remarks; - var preSocksPort = subItem?.PreSocksPort; - - List? lstProfiles = null; - //Is sing-box array configuration - if (lstProfiles is null || lstProfiles.Count <= 0) + // Safe Mode: Only allow full configuration if it's not from a subscription + var lstProfiles = V2rayFmt.ResolveToCustomOutbound(strData, subRemarks); + if (lstProfiles.Count == 0) { - lstProfiles = SingboxFmt.ResolveFullArray(strData, subRemarks); + lstProfiles = SingboxFmt.ResolveToCustomOutbound(strData, subRemarks); } - //Is v2ray array configuration - if (lstProfiles is null || lstProfiles.Count <= 0) - { - lstProfiles = V2rayFmt.ResolveFullArray(strData, subRemarks); - } - if (lstProfiles is { Count: > 0 }) - { - var count = 0; - foreach (var it in lstProfiles) - { - it.Subid = subid; - it.IsSub = isSub; - it.PreSocksPort = preSocksPort; - if (await AddCustomServer(config, it, true) == 0) - { - count++; - } - } - if (count > 0) - { - return count; - } - } - - ProfileItem? profileItem = null; - //Is sing-box configuration - profileItem ??= SingboxFmt.ResolveFull(strData, subRemarks); - //Is v2ray configuration - profileItem ??= V2rayFmt.ResolveFull(strData, subRemarks); - //Is Html Page - if (profileItem is null && HtmlPageFmt.IsHtmlPage(strData)) + if (lstProfiles.Count == 0) { return -1; } - //Is Clash configuration - profileItem ??= ClashFmt.ResolveFull(strData, subRemarks); - //Is hysteria configuration - profileItem ??= Hysteria2Fmt.ResolveFull2(strData, subRemarks); - if (profileItem is null || profileItem.Address.IsNullOrEmpty()) + + var count = await AddCustomOutboundServers(config, lstProfiles, subid, isSub); + if (count > 0) + { + return count; + } + + if (HtmlPageFmt.IsHtmlPage(strData)) + { + return -1; + } + + var profileItem = ClashFmt.ResolveFull(strData, subRemarks) + ?? Hysteria2Fmt.ResolveFull2(strData, subRemarks); + + if (profileItem == null) { return -1; } profileItem.Subid = subid; profileItem.IsSub = isSub; - profileItem.PreSocksPort = preSocksPort; - if (await AddCustomServer(config, profileItem, true) == 0) + profileItem.PreSocksPort = subItem?.PreSocksPort; + + return await AddCustomServer(config, profileItem, true) == 0 ? 1 : -1; + } + + private static async Task AddBatchServersSpecificCustom( + Config config, + string strData, + string subid, + bool isSub, + SubItem subItem) + { + var subRemarks = subItem.Remarks; + var customCoreType = subItem.CustomCoreType!.Value; + + List? lstProfiles = customCoreType switch { - return 1; + ECoreType.Xray => V2rayFmt.ResolveToCustom(strData, subRemarks), + ECoreType.sing_box => SingboxFmt.ResolveToCustom(strData, subRemarks), + _ => null + }; + + if (lstProfiles is not null) + { + if (lstProfiles.Count == 0) + { + return -1; + } + + var count = await AddCustomOutboundServers(config, lstProfiles, subid, isSub); + if (count > 0) + { + return count; + } } - else + + return await SaveCustomRawFileServer(config, strData, subid, isSub, subItem, customCoreType); + } + + private static async Task AddCustomOutboundServers( + Config config, + List lstProfiles, + string subid, + bool isSub) + { + var count = 0; + foreach (var it in lstProfiles) { - return -1; + it.Subid = subid; + it.IsSub = isSub; + if (await AddCustomOutboundServer(config, it, true) == 0) + { + count++; + } + } + return count; + } + + private static async Task SaveCustomRawFileServer( + Config config, + string strData, + string subid, + bool isSub, + SubItem subItem, + ECoreType customCoreType) + { + var ext = DetectFileExtension(strData); + var fileName = Utils.GetTempPath($"{Utils.GetGuid(false)}{ext}"); + await File.WriteAllTextAsync(fileName, strData); + + var profileItem = new ProfileItem + { + CoreType = customCoreType, + ConfigType = EConfigType.Custom, + Address = fileName, + Remarks = subItem.Remarks ?? customCoreType.ToString(), + Subid = subid, + IsSub = isSub, + PreSocksPort = subItem.PreSocksPort, + }; + + return await AddCustomServer(config, profileItem, true) == 0 ? 1 : -1; + + static string DetectFileExtension(string data) + { + var trimmed = data.AsSpan().TrimStart(); + if (trimmed.IsEmpty) + { + return string.Empty; + } + + if (trimmed[0] is '{' or '[') + { + return ".json"; + } + + if (trimmed.StartsWith("---")) + { + return ".yaml"; + } + + foreach (var line in trimmed.EnumerateLines()) + { + var lineTrimmed = line.TrimStart(); + if (lineTrimmed.IsEmpty || lineTrimmed.StartsWith("#")) + { + continue; + } + + var colonIndex = lineTrimmed.IndexOf(':'); + if (colonIndex > 0) + { + var keySpan = lineTrimmed[..colonIndex]; + if (!keySpan.Contains(' ') && !keySpan.Contains('\t')) + { + if (colonIndex == lineTrimmed.Length - 1 || lineTrimmed[colonIndex + 1] is ' ' or '\t' or '\r' or '\n') + { + return ".yaml"; + } + } + } + } + + return string.Empty; } } @@ -1837,6 +1995,7 @@ public static class ConfigHandler EConfigType.Anytls => await AddAnytlsServer(config, profileItem, false), EConfigType.Naive => await AddNaiveServer(config, profileItem, false), EConfigType.PolicyGroup or EConfigType.ProxyChain => await AddServerCommon(config, profileItem, false), + EConfigType.Outbound => await AddCustomOutboundServer(config, profileItem, true, false), _ => -1, }; if (addStatus == 0) @@ -2038,6 +2197,7 @@ public static class ConfigHandler item.NextProfile = subItem.NextProfile; item.PreSocksPort = subItem.PreSocksPort; item.Memo = subItem.Memo; + item.CustomCoreType = subItem.CustomCoreType; } if (item.Id.IsNullOrEmpty()) @@ -2078,7 +2238,7 @@ public static class ConfigHandler { return -1; } - var customProfile = await SQLiteHelper.Instance.TableAsync().Where(t => t.Subid == subid && t.ConfigType == EConfigType.Custom).ToListAsync(); + var customProfile = await SQLiteHelper.Instance.TableAsync().Where(t => t.Subid == subid && (t.ConfigType == EConfigType.Custom || t.ConfigType == EConfigType.Outbound)).ToListAsync(); if (isSub) { await SQLiteHelper.Instance.ExecuteAsync($"delete from ProfileItem where isSub = 1 and subid = '{subid}'"); diff --git a/v2rayN/ServiceLib/Handler/Fmt/InnerFmt.cs b/v2rayN/ServiceLib/Handler/Fmt/InnerFmt.cs index 3099a243..c39ab032 100644 --- a/v2rayN/ServiceLib/Handler/Fmt/InnerFmt.cs +++ b/v2rayN/ServiceLib/Handler/Fmt/InnerFmt.cs @@ -1,6 +1,6 @@ namespace ServiceLib.Handler.Fmt; -public class InnerFmt +public class InnerFmt : BaseFmt { private static readonly Lazy SessionSalt = new(() => Utils.GetGuid(false)); @@ -50,19 +50,19 @@ public class InnerFmt 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.IsNullOrEmpty()) if (protocolExtra.SubChildItems == "self") { protocolExtra = protocolExtra with { - SubChildItems = subid + SubChildItems = subid, }; } else { protocolExtra = protocolExtra with { - SubChildItems = null + SubChildItems = null, }; } if (Utils.String2List(protocolExtra.ChildItems) is { Count: > 0 } childIndexIds) @@ -73,14 +73,14 @@ public class InnerFmt .ToList(); protocolExtra = protocolExtra with { - ChildItems = Utils.List2String(newChildIndexIds) + ChildItems = Utils.List2String(newChildIndexIds), }; } else { protocolExtra = protocolExtra with { - ChildItems = null + ChildItems = null, }; } item.SetProtocolExtra(protocolExtra); @@ -120,7 +120,7 @@ public class InnerFmt { protocolExtra = protocolExtra with { - SubChildItems = "self" + SubChildItems = "self", }; } if (Utils.String2List(protocolExtra.ChildItems) is { Count: > 0 } childIndexIds) @@ -131,7 +131,7 @@ public class InnerFmt .ToList(); protocolExtra = protocolExtra with { - ChildItems = Utils.List2String(newChildIndexIds) + ChildItems = Utils.List2String(newChildIndexIds), }; } itemClone.SetProtocolExtra(protocolExtra); @@ -175,6 +175,19 @@ public class InnerFmt jsonObj["TransportExtra"] = JsonUtils.Serialize(transportExtraObj, false); jsonObj.Remove("TransportExtraObj"); } + var customOutboundFilePath = string.Empty; + if (jsonObj.TryGetPropertyValue("CustomOutboundObj", out var customOutboundNode) + && customOutboundNode is JsonObject customOutboundObj) + { + var customOutboundContent = JsonUtils.Serialize(customOutboundObj, new JsonSerializerOptions + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.Never, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }); + customOutboundFilePath = WriteAllText(customOutboundContent); + jsonObj.Remove("CustomOutboundObj"); + } var profileItem = JsonUtils.Deserialize(JsonUtils.Serialize(jsonObj, false)); if (profileItem is null) { @@ -193,6 +206,14 @@ public class InnerFmt { return null; } + if (profileItem.ConfigType is EConfigType.Outbound) + { + if (customOutboundFilePath.IsNullOrEmpty()) + { + return null; + } + profileItem.Address = customOutboundFilePath; + } var protocolExtra = profileItem.GetProtocolExtra(); var multipleLoad = protocolExtra.MultipleLoad; if (multipleLoad is not null && !Enum.IsDefined(typeof(EMultipleLoad), multipleLoad)) @@ -209,6 +230,26 @@ public class InnerFmt { return null; } + if (item.ConfigType is EConfigType.Outbound) + { + var customOutboundFilePath = item.Address; + if (!File.Exists(customOutboundFilePath)) + { + customOutboundFilePath = Utils.GetConfigPath(customOutboundFilePath); + } + if (!File.Exists(customOutboundFilePath)) + { + return null; + } + if (!customOutboundFilePath.IsNullOrEmpty() + && File.Exists(customOutboundFilePath) + && File.ReadAllText(customOutboundFilePath) is { Length: > 0 } customOutboundContent + && JsonUtils.ParseJson(customOutboundContent) is JsonObject customOutboundObj) + { + jsonObj["CustomOutboundObj"] = customOutboundObj; + jsonObj.Remove("Address"); + } + } // unflatten // move jsonObj.ProtoExtra (string) to jsonObj.ProtoExtraObj // move jsonObj.TransportExtra (string) to jsonObj.TransportExtraObj @@ -296,7 +337,7 @@ public class InnerFmt JsonValue value when value.TryGetValue(out var str) => string.IsNullOrEmpty(str), JsonObject obj => obj.Count == 0, JsonArray arr => arr.Count == 0, - _ => false + _ => false, }; } } diff --git a/v2rayN/ServiceLib/Handler/Fmt/SingboxFmt.cs b/v2rayN/ServiceLib/Handler/Fmt/SingboxFmt.cs index 969895d6..cc40166d 100644 --- a/v2rayN/ServiceLib/Handler/Fmt/SingboxFmt.cs +++ b/v2rayN/ServiceLib/Handler/Fmt/SingboxFmt.cs @@ -2,19 +2,102 @@ namespace ServiceLib.Handler.Fmt; public class SingboxFmt : BaseFmt { - public static List? ResolveFullArray(string strData, string? subRemarks) + public static List ResolveToCustom(string strData, string? subRemarks) { - var configObjects = JsonUtils.Deserialize(strData); - if (configObjects is not { Length: > 0 }) + var jsonNode = JsonUtils.ParseJson(strData); + return ResolveCommon(jsonNode, subRemarks, false); + } + + public static List ResolveToCustomOutbound(string strData, string? subRemarks) + { + var jsonNode = JsonUtils.ParseJson(strData); + return ResolveCommon(jsonNode, subRemarks, true); + } + + private static List ResolveCommon(JsonNode? jsonNode, string? subRemarks, bool isOutbound) + { + if (jsonNode is JsonArray jsonArray) + { + return + [ + .. jsonArray.Select(item => ResolveCommon(item, subRemarks, isOutbound)) + .Where(list => list is { Count: > 0 }) + .SelectMany(list => list), + ]; + } + if (jsonNode is not JsonObject jsonObject) + { + return []; + } + // Process the individual JSON object + var profileList = new List(); + if (!isOutbound) + { + var fullProfile = ResolveFull(jsonObject, subRemarks); + profileList.Add(fullProfile); + if (fullProfile is not null) + { + return profileList; + } + } + profileList.AddRange(ResolveFullToOutbound(jsonObject, subRemarks)); + if (profileList.Count != 0) + { + return profileList; + } + var outboundProfile = ResolveOutbound(jsonObject, subRemarks); + if (outboundProfile is not null) + { + profileList.Add(outboundProfile); + } + return profileList; + } + + private static ProfileItem? ResolveFull(JsonObject jsonObject, string? subRemarks) + { + if (jsonObject?["inbounds"] == null + || jsonObject["outbounds"] == null) { return null; } - List lstResult = []; - foreach (var configObject in configObjects) + if (jsonObject["outbounds"] is JsonArray outboundsArray) { - var objectString = JsonUtils.Serialize(configObject); - var profileIt = ResolveFull(objectString, subRemarks); + if (!outboundsArray.Any(IsValidSingboxOutbound)) + { + return null; + } + } + else + { + return null; + } + + var fileName = WriteAllText(JsonUtils.Serialize(jsonObject)); + var profileItem = new ProfileItem + { + CoreType = ECoreType.sing_box, + Address = fileName, + Remarks = subRemarks ?? "singbox_custom", + }; + + return profileItem; + } + + private static List ResolveFullToOutbound(JsonObject jsonObject, string? subRemarks) + { + if (jsonObject?["outbounds"] is not JsonArray outboundsArray) + { + return []; + } + List lstResult = []; + foreach (var outbound in outboundsArray) + { + if (outbound is not JsonObject outboundObj) + { + continue; + } + var profileIt = ResolveOutbound(outboundObj, subRemarks); if (profileIt != null) { lstResult.Add(profileIt); @@ -23,25 +106,58 @@ public class SingboxFmt : BaseFmt return lstResult; } - public static ProfileItem? ResolveFull(string strData, string? subRemarks) + private static ProfileItem? ResolveOutbound(JsonObject jsonObject, string? subRemarks) { - var config = JsonUtils.ParseJson(strData); - if (config?["inbounds"] == null - || config["outbounds"] == null - || config["route"] == null - || config["dns"] == null) + if (!IsValidSingboxOutbound(jsonObject)) { return null; } - - var fileName = WriteAllText(strData); + var type = jsonObject["type"]?.ToString(); + if (type is null or "direct" or "block" or "dns" or "selector" or "urltest") + { + return null; + } + var tag = jsonObject["tag"]?.ToString(); + var remarks = $"{type}_{tag}"; + var fileName = WriteAllText(JsonUtils.Serialize(jsonObject)); var profileItem = new ProfileItem { + ConfigType = EConfigType.Outbound, CoreType = ECoreType.sing_box, Address = fileName, - Remarks = subRemarks ?? "singbox_custom" + Remarks = remarks, }; - return profileItem; } + + private static bool IsValidSingboxOutbound(JsonNode? jsonNode) + { + if (jsonNode is not JsonObject jsonObject) + { + return false; + } + var matchedCounter = 0; + if (string.IsNullOrEmpty(jsonObject["type"]?.ToString())) + { + return false; + } + matchedCounter += 1; + if (!string.IsNullOrEmpty(jsonObject["tag"]?.ToString())) + { + matchedCounter += 1; + } + if (!string.IsNullOrEmpty(jsonObject["server"]?.ToString())) + { + matchedCounter += 1; + } + if (!string.IsNullOrEmpty(jsonObject["server_port"]?.ToString())) + { + matchedCounter += 1; + } + if (!string.IsNullOrEmpty(jsonObject["tls"]?.ToString())) + { + matchedCounter += 1; + } + return matchedCounter >= 2; + } } diff --git a/v2rayN/ServiceLib/Handler/Fmt/V2rayFmt.cs b/v2rayN/ServiceLib/Handler/Fmt/V2rayFmt.cs index 6db45fcd..a077062a 100644 --- a/v2rayN/ServiceLib/Handler/Fmt/V2rayFmt.cs +++ b/v2rayN/ServiceLib/Handler/Fmt/V2rayFmt.cs @@ -2,47 +2,165 @@ namespace ServiceLib.Handler.Fmt; public class V2rayFmt : BaseFmt { - public static List? ResolveFullArray(string strData, string? subRemarks) + public static List ResolveToCustom(string strData, string? subRemarks) { - var configObjects = JsonUtils.Deserialize(strData); - if (configObjects is not { Length: > 0 }) - { - return null; - } - - List lstResult = []; - foreach (var configObject in configObjects) - { - var objectString = JsonUtils.Serialize(configObject); - var profileIt = ResolveFull(objectString, subRemarks); - if (profileIt != null) - { - lstResult.Add(profileIt); - } - } - - return lstResult; + var jsonNode = JsonUtils.ParseJson(strData); + return ResolveCommon(jsonNode, subRemarks, false); } - public static ProfileItem? ResolveFull(string strData, string? subRemarks) + public static List ResolveToCustomOutbound(string strData, string? subRemarks) { - var config = JsonUtils.ParseJson(strData); - if (config?["inbounds"] == null - || config["outbounds"] == null - || config["routing"] == null) + var jsonNode = JsonUtils.ParseJson(strData); + return ResolveCommon(jsonNode, subRemarks, true); + } + + private static List ResolveCommon(JsonNode? jsonNode, string? subRemarks, bool isOutbound) + { + if (jsonNode is JsonArray jsonArray) + { + return + [ + .. jsonArray.Select(item => ResolveCommon(item, subRemarks, isOutbound)) + .Where(list => list is { Count: > 0 }) + .SelectMany(list => list), + ]; + } + if (jsonNode is not JsonObject jsonObject) + { + return []; + } + // Process the individual JSON object + var profileList = new List(); + if (!isOutbound) + { + var fullProfile = ResolveFull(jsonObject, subRemarks); + profileList.Add(fullProfile); + if (fullProfile is not null) + { + return profileList; + } + } + profileList.AddRange(ResolveFullToOutbound(jsonObject, subRemarks)); + if (profileList.Count != 0) + { + return profileList; + } + var outboundProfile = ResolveOutbound(jsonObject, subRemarks); + if (outboundProfile is not null) + { + profileList.Add(outboundProfile); + } + return profileList; + } + + private static ProfileItem? ResolveFull(JsonObject jsonObject, string? subRemarks) + { + if (jsonObject?["inbounds"] == null + || jsonObject["outbounds"] == null) { return null; } - var fileName = WriteAllText(strData); + if (jsonObject["outbounds"] is JsonArray outboundsArray) + { + if (!outboundsArray.Any(IsValidV2rayOutbound)) + { + return null; + } + } + else + { + return null; + } + + var fileName = WriteAllText(JsonUtils.Serialize(jsonObject)); var profileItem = new ProfileItem { CoreType = ECoreType.Xray, Address = fileName, - Remarks = config?["remarks"]?.ToString() ?? subRemarks ?? "v2ray_custom" + Remarks = jsonObject["remarks"]?.ToString() ?? subRemarks ?? "v2ray_custom", }; return profileItem; } + + public static List ResolveFullToOutbound(JsonObject jsonObject, string? subRemarks) + { + if (jsonObject["outbounds"] is not JsonArray outboundsArray) + { + return []; + } + List lstResult = []; + foreach (var outbound in outboundsArray) + { + if (outbound is not JsonObject outboundObj) + { + continue; + } + var profileIt = ResolveOutbound(outboundObj, subRemarks); + if (profileIt != null) + { + lstResult.Add(profileIt); + } + } + return lstResult; + } + + public static ProfileItem? ResolveOutbound(JsonObject jsonObject, string? subRemarks) + { + if (!IsValidV2rayOutbound(jsonObject)) + { + return null; + } + var protocol = jsonObject["protocol"]?.ToString(); + if (protocol is null or "freedom" or "blackhole" or "dns" or "loopback") + { + return null; + } + var tag = jsonObject["tag"]?.ToString(); + var remarks = $"{protocol}_{tag}"; + var fileName = WriteAllText(JsonUtils.Serialize(jsonObject)); + var profileItem = new ProfileItem + { + ConfigType = EConfigType.Outbound, + CoreType = ECoreType.Xray, + Address = fileName, + Remarks = remarks, + }; + return profileItem; + } + + private static bool IsValidV2rayOutbound(JsonNode? jsonNode) + { + if (jsonNode is not JsonObject jsonObject) + { + return false; + } + + var matchedCounter = 0; + if (string.IsNullOrEmpty(jsonObject["protocol"]?.ToString())) + { + return false; + } + matchedCounter += 1; + if (!string.IsNullOrEmpty(jsonObject["settings"]?.ToString())) + { + matchedCounter += 1; + } + if (!string.IsNullOrEmpty(jsonObject["streamSettings"]?.ToString())) + { + matchedCounter += 1; + } + if (!string.IsNullOrEmpty(jsonObject["tag"]?.ToString())) + { + matchedCounter += 1; + } + if (!string.IsNullOrEmpty(jsonObject["mux"]?.ToString())) + { + matchedCounter += 1; + } + + return matchedCounter >= 3; + } } diff --git a/v2rayN/ServiceLib/Manager/GroupProfileManager.cs b/v2rayN/ServiceLib/Manager/GroupProfileManager.cs index 1740960a..0a6f711b 100644 --- a/v2rayN/ServiceLib/Manager/GroupProfileManager.cs +++ b/v2rayN/ServiceLib/Manager/GroupProfileManager.cs @@ -118,7 +118,7 @@ public class GroupProfileManager return childProfiles?.Where(p => p != null && p.IsValid() && - !p.ConfigType.IsComplexType() && + (!p.ConfigType.IsComplexType() || p.ConfigType == EConfigType.Outbound) && (extra.Filter.IsNullOrEmpty() || Regex.IsMatch(p.Remarks, extra.Filter)) ) .ToList() ?? []; diff --git a/v2rayN/ServiceLib/Models/CoreConfigs/CoreConfigContext.cs b/v2rayN/ServiceLib/Models/CoreConfigs/CoreConfigContext.cs index b9561b1b..dde919f8 100644 --- a/v2rayN/ServiceLib/Models/CoreConfigs/CoreConfigContext.cs +++ b/v2rayN/ServiceLib/Models/CoreConfigs/CoreConfigContext.cs @@ -11,6 +11,8 @@ public record CoreConfigContext public Config AppConfig { get; init; } = new(); public FullConfigTemplateItem? FullConfigTemplate { get; init; } = new(); + public Dictionary CustomOutboundContent { get; init; } = new(); + // Test ServerTestItem Map public Dictionary ServerTestItemMap { get; init; } = new(); @@ -22,4 +24,7 @@ public record CoreConfigContext public bool IsWindows { get; init; } public bool IsMacOS { get; init; } + + // Generation Context + public Dictionary CustomOutboundMap { get; init; } = new(); } diff --git a/v2rayN/ServiceLib/Models/Entities/ProfileItem.cs b/v2rayN/ServiceLib/Models/Entities/ProfileItem.cs index 5daca988..375f43f6 100644 --- a/v2rayN/ServiceLib/Models/Entities/ProfileItem.cs +++ b/v2rayN/ServiceLib/Models/Entities/ProfileItem.cs @@ -66,7 +66,7 @@ public class ProfileItem public bool IsValid() { - if (IsComplex()) + if (IsComplex() || ConfigType == EConfigType.Outbound) { return true; } diff --git a/v2rayN/ServiceLib/Models/Entities/ProtocolExtraItem.cs b/v2rayN/ServiceLib/Models/Entities/ProtocolExtraItem.cs index 56bc958a..54e4479c 100644 --- a/v2rayN/ServiceLib/Models/Entities/ProtocolExtraItem.cs +++ b/v2rayN/ServiceLib/Models/Entities/ProtocolExtraItem.cs @@ -51,4 +51,7 @@ public record ProtocolExtraItem public string? SubChildItems { get; init; } public string? Filter { get; init; } public EMultipleLoad? MultipleLoad { get; init; } + + // custom outbound + public bool? IsSingboxEndpoint { get; init; } } diff --git a/v2rayN/ServiceLib/Models/Entities/SubItem.cs b/v2rayN/ServiceLib/Models/Entities/SubItem.cs index a66eb4ce..f96f40dd 100644 --- a/v2rayN/ServiceLib/Models/Entities/SubItem.cs +++ b/v2rayN/ServiceLib/Models/Entities/SubItem.cs @@ -33,4 +33,6 @@ public class SubItem public int? PreSocksPort { get; set; } public string? Memo { get; set; } + + public ECoreType? CustomCoreType { get; set; } } diff --git a/v2rayN/ServiceLib/Resx/ResUI.Designer.cs b/v2rayN/ServiceLib/Resx/ResUI.Designer.cs index e3dbdae6..5942024b 100644 --- a/v2rayN/ServiceLib/Resx/ResUI.Designer.cs +++ b/v2rayN/ServiceLib/Resx/ResUI.Designer.cs @@ -438,6 +438,15 @@ namespace ServiceLib.Resx { } } + /// + /// 查找类似 Custom config core 的本地化字符串。 + /// + public static string LvCustomCoreType { + get { + return ResourceManager.GetString("LvCustomCoreType", resourceCulture); + } + } + /// /// 查找类似 Custom icon 的本地化字符串。 /// @@ -735,6 +744,15 @@ namespace ServiceLib.Resx { } } + /// + /// 查找类似 Add a custom outbound 的本地化字符串。 + /// + public static string menuAddCustomOutboundServer { + get { + return ResourceManager.GetString("menuAddCustomOutboundServer", resourceCulture); + } + } + /// /// 查找类似 Add a custom configuration 的本地化字符串。 /// @@ -1933,7 +1951,7 @@ namespace ServiceLib.Resx { } /// - /// 查找类似 Warning: Xray will disable allowInsecure (skip certificate verification) in August 2026. Please switch to pinnedPeerCertSha256 (fixed certificate fingerprint) as soon as possible. allowInsecure will not be usable after its expiration. 的本地化字符串。 + /// 查找类似 The current node uses an unencrypted connection, meaning your communications could be directly monitored by network intermediaries controlled by authoritarian governments. For security reasons, nodes of this type cannot connect via Xray-core versions 26.2.6 or higher. If this is a self-built node, please enable TLS or other secure encryption, or pin the certificate using pinSHA256. If this is an airport/provider node, please contact your service provider for a technical upgrade. If the provider refuses to c [字符串的其余部分被截断]"; 的本地化字符串。 /// public static string MsgAllowInsecureDeprecated { get { @@ -1977,6 +1995,15 @@ namespace ServiceLib.Resx { } } + /// + /// 查找类似 Custom outbound {0} file not found: {1} 的本地化字符串。 + /// + public static string MsgCustomOutboundFileNotFound { + get { + return ResourceManager.GetString("MsgCustomOutboundFileNotFound", resourceCulture); + } + } + /// /// 查找类似 Downloaded GeoFile: {0} successfully 的本地化字符串。 /// @@ -2925,6 +2952,15 @@ namespace ServiceLib.Resx { } } + /// + /// 查找类似 Only single outbound/endpoint supported for xray/sing-box 的本地化字符串。 + /// + public static string TbCustomOutboundTip { + get { + return ResourceManager.GetString("TbCustomOutboundTip", resourceCulture); + } + } + /// /// 查找类似 Direct Target Resolution Strategy 的本地化字符串。 /// diff --git a/v2rayN/ServiceLib/Resx/ResUI.resx b/v2rayN/ServiceLib/Resx/ResUI.resx index 9100bcd8..0c66c849 100644 --- a/v2rayN/ServiceLib/Resx/ResUI.resx +++ b/v2rayN/ServiceLib/Resx/ResUI.resx @@ -1848,4 +1848,16 @@ The "Get Certificate" action may fail if a self-signed certificate is used or if Ipv4 Address + + Add a custom outbound + + + Custom outbound {0} file not found: {1} + + + Only single outbound/endpoint supported for xray/sing-box + + + Custom config core + \ 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 ada3f03f..1dfc0378 100644 --- a/v2rayN/ServiceLib/Resx/ResUI.zh-Hans.resx +++ b/v2rayN/ServiceLib/Resx/ResUI.zh-Hans.resx @@ -1849,4 +1849,16 @@ Ipv4 地址 + + 添加自定义出站 + + + 自定义出站 {0} 的文件未找到:{1} + + + 仅支持 xray/sing-box 的单个 outbound/endpoin + + + 自定义配置核心 + \ No newline at end of file diff --git a/v2rayN/ServiceLib/Services/CoreConfig/Singbox/CoreConfigSingboxService.cs b/v2rayN/ServiceLib/Services/CoreConfig/Singbox/CoreConfigSingboxService.cs index df8ae76c..399ec91e 100644 --- a/v2rayN/ServiceLib/Services/CoreConfig/Singbox/CoreConfigSingboxService.cs +++ b/v2rayN/ServiceLib/Services/CoreConfig/Singbox/CoreConfigSingboxService.cs @@ -57,13 +57,10 @@ public partial class CoreConfigSingboxService(CoreConfigContext context) ConvertGeo2Ruleset(); - ApplyOutboundBindInterface(); - ApplyOutboundSendThrough(); - ret.Msg = string.Format(ResUI.SuccessfulConfiguration, ""); ret.Success = true; - ret.Data = ApplyFullConfigTemplate(); + ret.Data = ApplyFinalConfigModifiers(); return ret; } catch (Exception ex) @@ -107,7 +104,7 @@ public partial class CoreConfigSingboxService(CoreConfigContext context) foreach (var it in selecteds) { - if (!(Global.SingboxSupportConfigType.Contains(it.ConfigType) || it.ConfigType.IsGroupType())) + if (!(Global.SingboxSupportConfigType.Contains(it.ConfigType) || it.ConfigType.IsGroupType() || it.ConfigType is EConfigType.Outbound)) { continue; } @@ -174,7 +171,7 @@ public partial class CoreConfigSingboxService(CoreConfigContext context) ApplyOutboundBindInterface(); ApplyOutboundSendThrough(); ret.Success = true; - ret.Data = JsonUtils.Serialize(_coreConfig); + ret.Data = ApplyCustomOutboundReplace(); return ret; } catch (Exception ex) @@ -236,7 +233,7 @@ public partial class CoreConfigSingboxService(CoreConfigContext context) ret.Msg = string.Format(ResUI.SuccessfulConfiguration, ""); ret.Success = true; - ret.Data = JsonUtils.Serialize(_coreConfig); + ret.Data = ApplyCustomOutboundReplace(); return ret; } catch (Exception ex) diff --git a/v2rayN/ServiceLib/Services/CoreConfig/Singbox/SingboxConfigTemplateService.cs b/v2rayN/ServiceLib/Services/CoreConfig/Singbox/SingboxConfigTemplateService.cs index c9d1193a..c08c29c6 100644 --- a/v2rayN/ServiceLib/Services/CoreConfig/Singbox/SingboxConfigTemplateService.cs +++ b/v2rayN/ServiceLib/Services/CoreConfig/Singbox/SingboxConfigTemplateService.cs @@ -2,54 +2,137 @@ namespace ServiceLib.Services.CoreConfig; public partial class CoreConfigSingboxService { - private string ApplyFullConfigTemplate() + private string ApplyFinalConfigModifiers() + { + ApplyOutboundBindInterface(); + ApplyOutboundSendThrough(); + + var coreConfigContent = ApplyCustomOutboundReplace(); + + return ApplyFullConfigTemplate(coreConfigContent); + } + + private string ApplyCustomOutboundReplace() + { + var coreConfigContent = JsonUtils.Serialize(_coreConfig); + if (context.CustomOutboundMap.Count == 0) + { + return coreConfigContent; + } + var coreConfigNode = JsonNode.Parse(coreConfigContent) as JsonObject; + var coreConfigOutboundsNode = coreConfigNode?["outbounds"] as JsonArray ?? []; + ReplaceCustomOutbounds(_coreConfig.outbounds, coreConfigOutboundsNode); + coreConfigNode!["outbounds"] = coreConfigOutboundsNode; + var coreConfigEndpointsNode = coreConfigNode?["endpoints"] as JsonArray ?? []; + ReplaceCustomOutbounds(_coreConfig.endpoints, coreConfigEndpointsNode); + if (coreConfigEndpointsNode.Count > 0) + { + coreConfigNode!["endpoints"] = coreConfigEndpointsNode; + } + else + { + coreConfigNode?.Remove("endpoints"); + } + return JsonUtils.Serialize(coreConfigNode); + + void ReplaceCustomOutbounds(IReadOnlyList? source, JsonArray jsonArrayOutbounds) + { + foreach (var outbound in source ?? []) + { + if (!context.CustomOutboundMap.TryGetValue(outbound, out var customOutboundIndex)) + { + continue; + } + var outboundTag = outbound.tag; + var outboundDetour = outbound.detour ?? string.Empty; + var outboundBindInterface = outbound.bind_interface ?? string.Empty; + var customOutboundContent = context.CustomOutboundContent[customOutboundIndex]; + var containTagPlaceholder = customOutboundContent.Contains("{{tag}}"); + var containDetourPlaceholder = customOutboundContent.Contains("{{detour}}"); + var containBindInterfacePlaceholder = customOutboundContent.Contains("{{interface}}"); + customOutboundContent = customOutboundContent.Replace("{{tag}}", outboundTag); + customOutboundContent = customOutboundContent.Replace("{{detour}}", outboundDetour); + customOutboundContent = customOutboundContent.Replace("{{interface}}", outboundBindInterface); + var customOutboundObj = JsonUtils.ParseJson(customOutboundContent) as JsonObject; + + if (!containTagPlaceholder) + { + customOutboundObj?["tag"] = outboundTag; + } + if (!containDetourPlaceholder && !outboundDetour.IsNullOrEmpty()) + { + customOutboundObj?["detour"] = outboundDetour; + } + else if (outboundDetour.IsNullOrEmpty()) + { + customOutboundObj?.Remove("detour"); + } + if (!containBindInterfacePlaceholder && !outboundBindInterface.IsNullOrEmpty()) + { + customOutboundObj?["bind_interface"] = outboundBindInterface; + } + + var index = jsonArrayOutbounds + .Select((node, idx) => new { node, idx }) + .FirstOrDefault(x => x.node?["tag"]?.ToString() == outboundTag)?.idx ?? -1; + if (index != -1) + { + jsonArrayOutbounds[index] = customOutboundObj; + } + } + } + } + + private string ApplyFullConfigTemplate(string coreConfigContent) { var fullConfigTemplate = context.FullConfigTemplate; if (fullConfigTemplate is not { Enabled: true }) { - return JsonUtils.Serialize(_coreConfig); + return coreConfigContent; } var fullConfigTemplateItem = context.IsTunEnabled ? fullConfigTemplate.TunConfig : fullConfigTemplate.Config; if (fullConfigTemplateItem.IsNullOrEmpty()) { - return JsonUtils.Serialize(_coreConfig); + return coreConfigContent; } var fullConfigTemplateNode = JsonNode.Parse(fullConfigTemplateItem); if (fullConfigTemplateNode == null) { - return JsonUtils.Serialize(_coreConfig); + return coreConfigContent; } // Process outbounds var customOutboundsNode = fullConfigTemplateNode["outbounds"] as JsonArray ?? []; - foreach (var outbound in _coreConfig.outbounds) + var coreConfigNode = JsonNode.Parse(coreConfigContent); + var coreConfigOutboundsNode = coreConfigNode?["outbounds"] as JsonArray ?? []; + foreach (var outbound in coreConfigOutboundsNode) { - if (outbound.type.ToLower() is "direct" or "block") + if (outbound["type"]?.ToString()?.ToLower() is "direct" or "block") { if (fullConfigTemplate.AddProxyOnly == true) { continue; } } - else if (outbound.detour.IsNullOrEmpty() && !fullConfigTemplate.ProxyDetour.IsNullOrEmpty() && !Utils.IsPrivateNetwork(outbound.server ?? string.Empty)) + if (outbound["detour"] is null && !fullConfigTemplate.ProxyDetour.IsNullOrEmpty() && !Utils.IsPrivateNetwork(outbound["server"]?.ToString() ?? string.Empty)) { - outbound.detour = fullConfigTemplate.ProxyDetour; + outbound["detour"] = fullConfigTemplate.ProxyDetour; } customOutboundsNode.Add(JsonUtils.DeepCopy(outbound)); } fullConfigTemplateNode["outbounds"] = customOutboundsNode; // Process endpoints - if (_coreConfig.endpoints is { Count: > 0 }) + if (fullConfigTemplateNode["endpoints"] is JsonArray { Count: > 0 } coreConfigEndpointsNode) { var customEndpointsNode = fullConfigTemplateNode["endpoints"] as JsonArray ?? []; - foreach (var endpoint in _coreConfig.endpoints) + foreach (var endpoint in coreConfigEndpointsNode) { - if (endpoint.detour.IsNullOrEmpty() && !fullConfigTemplate.ProxyDetour.IsNullOrEmpty()) + if (endpoint["detour"] is null && !fullConfigTemplate.ProxyDetour.IsNullOrEmpty()) { - endpoint.detour = fullConfigTemplate.ProxyDetour; + endpoint["detour"] = fullConfigTemplate.ProxyDetour; } customEndpointsNode.Add(JsonUtils.DeepCopy(endpoint)); } diff --git a/v2rayN/ServiceLib/Services/CoreConfig/Singbox/SingboxOutboundService.cs b/v2rayN/ServiceLib/Services/CoreConfig/Singbox/SingboxOutboundService.cs index 99cdbd29..83f43d2f 100644 --- a/v2rayN/ServiceLib/Services/CoreConfig/Singbox/SingboxOutboundService.cs +++ b/v2rayN/ServiceLib/Services/CoreConfig/Singbox/SingboxOutboundService.cs @@ -11,7 +11,7 @@ public partial class CoreConfigSingboxService private List BuildAllProxyOutbounds(string baseTagName = Global.ProxyTag, bool withSelector = true) { var proxyOutboundList = new List(); - if (!_node.ConfigType.IsComplexType()) + if (!_node.ConfigType.IsGroupType()) { var outbound = BuildProxyOutbound(baseTagName); proxyOutboundList.Add(outbound); @@ -35,6 +35,10 @@ public partial class CoreConfigSingboxService { var outbound = BuildProxyServer(); outbound.tag = baseTagName; + if (_node.ConfigType == EConfigType.Outbound) + { + context.CustomOutboundMap[outbound] = _node.IndexId; + } return outbound; } @@ -59,6 +63,20 @@ public partial class CoreConfigSingboxService try { var txtOutbound = EmbedUtils.GetEmbedText(Global.SingboxSampleOutbound); + if (_node.ConfigType == EConfigType.Outbound) + { + if (_node.GetProtocolExtra().IsSingboxEndpoint == true) + { + var endpoint = JsonUtils.Deserialize(txtOutbound); + return endpoint; + } + else + { + var outbound = JsonUtils.Deserialize(txtOutbound); + return outbound; + } + } + if (_node.ConfigType == EConfigType.WireGuard) { var endpoint = JsonUtils.Deserialize(txtOutbound); @@ -593,7 +611,7 @@ public partial class CoreConfigSingboxService { type = "selector", tag = baseTagName, - outbounds = JsonUtils.DeepCopy(proxyTags), + outbounds = [.. proxyTags], interrupt_exist_connections = false, }; outSelector.outbounds.Insert(0, outUrltest.tag); @@ -721,9 +739,9 @@ public partial class CoreConfigSingboxService return resultOutbounds; } - private static List CloneOutbounds(List source) + private List CloneOutbounds(List source) { - if (source is null || source.Count == 0) + if (source is not { Count: > 0 }) { return []; } @@ -740,9 +758,14 @@ public partial class CoreConfigSingboxService { clone = JsonUtils.DeepCopy(endpoint); } - if (clone is not null) + if (clone is null) { - result.Add(clone); + continue; + } + result.Add(clone); + if (context.CustomOutboundMap.ContainsKey(item)) + { + context.CustomOutboundMap[clone] = context.CustomOutboundMap[item]; } } return result; diff --git a/v2rayN/ServiceLib/Services/CoreConfig/Singbox/SingboxRoutingService.cs b/v2rayN/ServiceLib/Services/CoreConfig/Singbox/SingboxRoutingService.cs index 9cc4a169..2a0e2dc2 100644 --- a/v2rayN/ServiceLib/Services/CoreConfig/Singbox/SingboxRoutingService.cs +++ b/v2rayN/ServiceLib/Services/CoreConfig/Singbox/SingboxRoutingService.cs @@ -574,7 +574,8 @@ public partial class CoreConfigSingboxService if (node == null || (!Global.SingboxSupportConfigType.Contains(node.ConfigType) - && !node.ConfigType.IsGroupType())) + && !node.ConfigType.IsGroupType() + && node.ConfigType is not EConfigType.Outbound)) { return Global.ProxyTag; } diff --git a/v2rayN/ServiceLib/Services/CoreConfig/V2ray/CoreConfigV2rayService.cs b/v2rayN/ServiceLib/Services/CoreConfig/V2ray/CoreConfigV2rayService.cs index 4dbc72e8..bee41134 100644 --- a/v2rayN/ServiceLib/Services/CoreConfig/V2ray/CoreConfigV2rayService.cs +++ b/v2rayN/ServiceLib/Services/CoreConfig/V2ray/CoreConfigV2rayService.cs @@ -64,8 +64,6 @@ public partial class CoreConfigV2rayService(CoreConfigContext context) { ApplyFinalFragment(); } - ApplyOutboundBindInterface(); - ApplyOutboundSendThrough(); var finalRule = BuildFinalRule(); if (!string.IsNullOrEmpty(finalRule?.balancerTag)) @@ -75,7 +73,7 @@ public partial class CoreConfigV2rayService(CoreConfigContext context) ret.Msg = string.Format(ResUI.SuccessfulConfiguration, ""); ret.Success = true; - ret.Data = ApplyFullConfigTemplate(); + ret.Data = ApplyFinalConfigModifiers(); return ret; } catch (Exception ex) @@ -119,7 +117,7 @@ public partial class CoreConfigV2rayService(CoreConfigContext context) foreach (var it in selecteds) { - if (!(Global.XraySupportConfigType.Contains(it.ConfigType) || it.ConfigType.IsGroupType())) + if (!(Global.XraySupportConfigType.Contains(it.ConfigType) || it.ConfigType.IsGroupType() || it.ConfigType is EConfigType.Outbound)) { continue; } @@ -216,7 +214,7 @@ public partial class CoreConfigV2rayService(CoreConfigContext context) ApplyOutboundSendThrough(); //ret.Msg =string.Format(ResUI.SuccessfulConfiguration"), node.getSummary()); ret.Success = true; - ret.Data = JsonUtils.Serialize(_coreConfig); + ret.Data = ApplyCustomOutboundReplace(); return ret; } catch (Exception ex) @@ -293,7 +291,7 @@ public partial class CoreConfigV2rayService(CoreConfigContext context) ret.Msg = string.Format(ResUI.SuccessfulConfiguration, ""); ret.Success = true; - ret.Data = JsonUtils.Serialize(_coreConfig); + ret.Data = ApplyCustomOutboundReplace(); return ret; } catch (Exception ex) diff --git a/v2rayN/ServiceLib/Services/CoreConfig/V2ray/V2rayConfigTemplateService.cs b/v2rayN/ServiceLib/Services/CoreConfig/V2ray/V2rayConfigTemplateService.cs index 8d076390..6f57268c 100644 --- a/v2rayN/ServiceLib/Services/CoreConfig/V2ray/V2rayConfigTemplateService.cs +++ b/v2rayN/ServiceLib/Services/CoreConfig/V2ray/V2rayConfigTemplateService.cs @@ -2,24 +2,105 @@ namespace ServiceLib.Services.CoreConfig; public partial class CoreConfigV2rayService { - private string ApplyFullConfigTemplate() + private string ApplyFinalConfigModifiers() + { + ApplyOutboundBindInterface(); + ApplyOutboundSendThrough(); + + var coreConfigContent = ApplyCustomOutboundReplace(); + + return ApplyFullConfigTemplate(coreConfigContent); + } + + private string ApplyCustomOutboundReplace() + { + var coreConfigContent = JsonUtils.Serialize(_coreConfig); + if (context.CustomOutboundMap.Count == 0) + { + return coreConfigContent; + } + var coreConfigNode = JsonNode.Parse(coreConfigContent) as JsonObject; + var coreConfigOutboundsNode = coreConfigNode?["outbounds"] as JsonArray ?? []; + + foreach (var outbound in _coreConfig.outbounds ?? []) + { + if (!context.CustomOutboundMap.TryGetValue(outbound, out var customOutboundIndex)) + { + continue; + } + var outboundTag = outbound.tag; + var outboundDetour = outbound.streamSettings?.sockopt?.dialerProxy ?? string.Empty; + var outboundBindInterface = outbound.streamSettings?.sockopt?.Interface ?? string.Empty; + var customOutboundContent = context.CustomOutboundContent[customOutboundIndex]; + var containTagPlaceholder = customOutboundContent.Contains("{{tag}}"); + var containDetourPlaceholder = customOutboundContent.Contains("{{detour}}"); + var containBindInterfacePlaceholder = customOutboundContent.Contains("{{interface}}"); + customOutboundContent = customOutboundContent.Replace("{{tag}}", outboundTag); + customOutboundContent = customOutboundContent.Replace("{{detour}}", outboundDetour); + customOutboundContent = customOutboundContent.Replace("{{interface}}", outboundBindInterface); + var customOutboundObj = JsonUtils.ParseJson(customOutboundContent) as JsonObject; + + if (!containTagPlaceholder) + { + customOutboundObj?["tag"] = outboundTag; + } + if (!containDetourPlaceholder && !outboundDetour.IsNullOrEmpty()) + { + customOutboundObj!["streamSettings"] ??= new JsonObject(); + customOutboundObj["streamSettings"]["sockopt"] ??= new JsonObject(); + customOutboundObj["streamSettings"]["sockopt"]["dialerProxy"] = outboundDetour; + if (customOutboundObj["streamSettings"]?["xhttpSettings"]?["extra"]?["downloadSettings"] is JsonObject downloadSettings) + { + downloadSettings["sockopt"] ??= new JsonObject(); + downloadSettings["sockopt"]["dialerProxy"] = outboundDetour; + } + } + else if (outboundDetour.IsNullOrEmpty()) + { + (customOutboundObj?["streamSettings"]?["sockopt"] as JsonObject)?.Remove("dialerProxy"); + } + if (!containBindInterfacePlaceholder && !outboundBindInterface.IsNullOrEmpty()) + { + customOutboundObj!["streamSettings"] ??= new JsonObject(); + customOutboundObj["streamSettings"]["sockopt"] ??= new JsonObject(); + customOutboundObj["streamSettings"]["sockopt"]["interface"] = outboundBindInterface; + if (customOutboundObj["streamSettings"]?["xhttpSettings"]?["extra"]?["downloadSettings"] is JsonObject downloadSettings) + { + downloadSettings["sockopt"] ??= new JsonObject(); + downloadSettings["sockopt"]["interface"] = outboundBindInterface; + } + } + + var index = coreConfigOutboundsNode + .Select((node, idx) => new { node, idx }) + .FirstOrDefault(x => x.node?["tag"]?.ToString() == outboundTag)?.idx ?? -1; + if (index != -1) + { + coreConfigOutboundsNode[index] = customOutboundObj; + } + } + + return JsonUtils.Serialize(coreConfigNode); + } + + private string ApplyFullConfigTemplate(string coreConfigContent) { var fullConfigTemplate = context.FullConfigTemplate; if (fullConfigTemplate is not { Enabled: true }) { - return JsonUtils.Serialize(_coreConfig); + return coreConfigContent; } var fullConfigTemplateItem = context.IsTunEnabled ? fullConfigTemplate.TunConfig : fullConfigTemplate.Config; if (fullConfigTemplateItem.IsNullOrEmpty()) { - return JsonUtils.Serialize(_coreConfig); + return coreConfigContent; } var fullConfigTemplateNode = JsonNode.Parse(fullConfigTemplateItem); if (fullConfigTemplateNode == null) { - return JsonUtils.Serialize(_coreConfig); + return coreConfigContent; } // Handle balancer and rules modifications (for multiple load scenarios) @@ -74,8 +155,8 @@ public partial class CoreConfigV2rayService else { var subjectSelector = _coreConfig.observatory.subjectSelector; - subjectSelector.AddRange(fullConfigTemplateNode["observatory"]?["subjectSelector"]?.AsArray()?.Select(x => x?.GetValue()) ?? []); - fullConfigTemplateNode["observatory"]["subjectSelector"] = JsonNode.Parse(JsonUtils.Serialize(subjectSelector.Distinct().ToList())); + subjectSelector?.AddRange(fullConfigTemplateNode["observatory"]?["subjectSelector"]?.AsArray()?.Select(x => x?.GetValue()) ?? []); + fullConfigTemplateNode["observatory"]?["subjectSelector"] = JsonNode.Parse(JsonUtils.Serialize(subjectSelector?.Distinct().ToList())); } } @@ -88,16 +169,18 @@ public partial class CoreConfigV2rayService else { var subjectSelector = _coreConfig.burstObservatory.subjectSelector; - subjectSelector.AddRange(fullConfigTemplateNode["burstObservatory"]?["subjectSelector"]?.AsArray()?.Select(x => x?.GetValue()) ?? []); - fullConfigTemplateNode["burstObservatory"]["subjectSelector"] = JsonNode.Parse(JsonUtils.Serialize(subjectSelector.Distinct().ToList())); + subjectSelector?.AddRange(fullConfigTemplateNode["burstObservatory"]?["subjectSelector"]?.AsArray()?.Select(x => x?.GetValue()) ?? []); + fullConfigTemplateNode["burstObservatory"]?["subjectSelector"] = JsonNode.Parse(JsonUtils.Serialize(subjectSelector?.Distinct().ToList())); } } var customOutboundsNode = new JsonArray(); - foreach (var outbound in _coreConfig.outbounds) + var coreConfigNode = JsonNode.Parse(coreConfigContent); + var coreConfigOutboundsNode = coreConfigNode?["outbounds"] as JsonArray ?? []; + foreach (var outbound in coreConfigOutboundsNode) { - if (outbound.protocol.ToLower() is "blackhole" or "dns" or "freedom") + if (outbound?["protocol"]?.ToString()?.ToLower() is "blackhole" or "dns" or "freedom") { if (fullConfigTemplate.AddProxyOnly == true) { @@ -105,14 +188,22 @@ public partial class CoreConfigV2rayService } } else if (!fullConfigTemplate.ProxyDetour.IsNullOrEmpty() - && (outbound.streamSettings?.sockopt?.dialerProxy.IsNullOrEmpty() ?? true)) + && (outbound["streamSettings"]?["sockopt"]?["dialerProxy"].ToString().IsNullOrEmpty() ?? true)) { - var outboundAddress = outbound.settings?.servers?.FirstOrDefault()?.address - ?? outbound.settings?.vnext?.FirstOrDefault()?.address + var outboundAddress = outbound["settings"]?["servers"]?.AsArray()?.FirstOrDefault()?["address"]?.ToString() + ?? outbound["settings"]?["vnext"]?.AsArray()?.FirstOrDefault()?["address"]?.ToString() ?? string.Empty; if (!Utils.IsPrivateNetwork(outboundAddress)) { - FillDialerProxy(outbound, fullConfigTemplate.ProxyDetour); + //FillDialerProxy(outbound, fullConfigTemplate.ProxyDetour); + outbound["streamSettings"] ??= new JsonObject(); + outbound["streamSettings"]["sockopt"] ??= new JsonObject(); + outbound["streamSettings"]["sockopt"]["dialerProxy"] = fullConfigTemplate.ProxyDetour; + if (outbound["streamSettings"]?["xhttpSettings"]?["extra"]?["downloadSettings"] is JsonObject downloadSettings) + { + downloadSettings["sockopt"] ??= new JsonObject(); + downloadSettings["sockopt"]["dialerProxy"] = fullConfigTemplate.ProxyDetour; + } } } customOutboundsNode.Add(JsonUtils.DeepCopy(outbound)); diff --git a/v2rayN/ServiceLib/Services/CoreConfig/V2ray/V2rayOutboundService.cs b/v2rayN/ServiceLib/Services/CoreConfig/V2ray/V2rayOutboundService.cs index 585bade1..bc627f7b 100644 --- a/v2rayN/ServiceLib/Services/CoreConfig/V2ray/V2rayOutboundService.cs +++ b/v2rayN/ServiceLib/Services/CoreConfig/V2ray/V2rayOutboundService.cs @@ -52,6 +52,12 @@ public partial class CoreConfigV2rayService { var txtOutbound = EmbedUtils.GetEmbedText(Global.V2raySampleOutbound); var outbound = JsonUtils.Deserialize(txtOutbound); + if (_node.ConfigType == EConfigType.Outbound) + { + outbound.tag = baseTagName; + context.CustomOutboundMap[outbound] = _node.IndexId; + return outbound; + } FillOutbound(outbound); outbound.tag = baseTagName; return outbound; @@ -788,12 +794,12 @@ public partial class CoreConfigV2rayService } else if (chainStartNodes.Count > 1) { - var existedChainNodes = JsonUtils.DeepCopy(resultOutbounds); + var existedChainNodes = CloneOutbounds(resultOutbounds); resultOutbounds.Clear(); var j = 0; foreach (var chainStartNode in chainStartNodes) { - var existedChainNodesClone = JsonUtils.DeepCopy(existedChainNodes); + var existedChainNodesClone = CloneOutbounds(existedChainNodes); foreach (var existedChainNode in existedChainNodesClone) { var cloneTag = $"{existedChainNode.tag}-clone-{j + 1}"; @@ -955,4 +961,19 @@ public partial class CoreConfigV2rayService return fragmentMask; } + + private List CloneOutbounds(List outbounds) + { + var clonedOutbounds = new List(); + foreach (var outbound in outbounds) + { + var clonedOutbound = JsonUtils.DeepCopy(outbound); + clonedOutbounds.Add(clonedOutbound); + if (context.CustomOutboundMap.ContainsKey(outbound)) + { + context.CustomOutboundMap[clonedOutbound] = context.CustomOutboundMap[outbound]; + } + } + return clonedOutbounds; + } } diff --git a/v2rayN/ServiceLib/Services/CoreConfig/V2ray/V2rayRoutingService.cs b/v2rayN/ServiceLib/Services/CoreConfig/V2ray/V2rayRoutingService.cs index fed39b47..69dc963b 100644 --- a/v2rayN/ServiceLib/Services/CoreConfig/V2ray/V2rayRoutingService.cs +++ b/v2rayN/ServiceLib/Services/CoreConfig/V2ray/V2rayRoutingService.cs @@ -188,7 +188,8 @@ public partial class CoreConfigV2rayService if (node == null || (!Global.XraySupportConfigType.Contains(node.ConfigType) - && !node.ConfigType.IsGroupType())) + && !node.ConfigType.IsGroupType() + && node.ConfigType is not EConfigType.Outbound)) { return Global.ProxyTag; } diff --git a/v2rayN/ServiceLib/ViewModels/AddServer2ViewModel.cs b/v2rayN/ServiceLib/ViewModels/AddServer2ViewModel.cs index b0fdfe1f..5a35eeea 100644 --- a/v2rayN/ServiceLib/ViewModels/AddServer2ViewModel.cs +++ b/v2rayN/ServiceLib/ViewModels/AddServer2ViewModel.cs @@ -12,6 +12,9 @@ public partial class AddServer2ViewModel : MyReactiveObject, ICloseable [Reactive] public partial string? CoreType { get; set; } + [Reactive] + public partial bool IsSingboxEndpoint { get; set; } + public ReactiveCommand BrowseServerCmd { get; } public ReactiveCommand EditServerCmd { get; } public ReactiveCommand SaveServerCmd { get; } @@ -40,7 +43,10 @@ public partial class AddServer2ViewModel : MyReactiveObject, ICloseable }); SelectedSource = profileItem.IndexId.IsNullOrEmpty() ? profileItem : JsonUtils.DeepCopy(profileItem); - CoreType = SelectedSource?.CoreType?.ToString(); + var coreStr = SelectedSource?.CoreType?.ToString(); + coreStr = coreStr.IsNullOrEmpty() ? Global.CoreTypes.FirstOrDefault() : coreStr; + CoreType = coreStr; + IsSingboxEndpoint = SelectedSource?.GetProtocolExtra()?.IsSingboxEndpoint ?? false; } private async Task SaveServerAsync() @@ -58,6 +64,10 @@ public partial class AddServer2ViewModel : MyReactiveObject, ICloseable return; } SelectedSource.CoreType = CoreType.IsNullOrEmpty() ? null : Enum.Parse(CoreType); + SelectedSource.SetProtocolExtra(SelectedSource?.GetProtocolExtra() with + { + IsSingboxEndpoint = IsSingboxEndpoint ? true : null, + }); if (await ConfigHandler.EditCustomServer(_config, SelectedSource) == 0) { @@ -80,7 +90,8 @@ public partial class AddServer2ViewModel : MyReactiveObject, ICloseable var item = await AppManager.Instance.GetProfileItem(SelectedSource.IndexId); item ??= SelectedSource; item.Address = fileName; - if (await ConfigHandler.AddCustomServer(_config, item, false) == 0) + var result = item.ConfigType == EConfigType.Outbound ? await ConfigHandler.AddCustomOutboundServer(_config, item, false) : await ConfigHandler.AddCustomServer(_config, item, false); + if (result == 0) { NoticeManager.Instance.Enqueue(ResUI.SuccessfullyImportedCustomServer); if (item.IndexId.IsNotEmpty()) diff --git a/v2rayN/ServiceLib/ViewModels/MainWindowViewModel.cs b/v2rayN/ServiceLib/ViewModels/MainWindowViewModel.cs index 1664c6a6..d167a02a 100644 --- a/v2rayN/ServiceLib/ViewModels/MainWindowViewModel.cs +++ b/v2rayN/ServiceLib/ViewModels/MainWindowViewModel.cs @@ -33,6 +33,7 @@ public partial class MainWindowViewModel : MyReactiveObject public ReactiveCommand AddAnytlsServerCmd { get; } public ReactiveCommand AddNaiveServerCmd { get; } public ReactiveCommand AddCustomServerCmd { get; } + public ReactiveCommand AddCustomOutboundServerCmd { get; } public ReactiveCommand AddPolicyGroupServerCmd { get; } public ReactiveCommand AddProxyChainServerCmd { get; } public ReactiveCommand AddServerViaClipboardCmd { get; } @@ -145,6 +146,10 @@ public partial class MainWindowViewModel : MyReactiveObject { await AddServerAsync(EConfigType.Custom); }); + AddCustomOutboundServerCmd = ReactiveCommand.CreateFromTask(async () => + { + await AddServerAsync(EConfigType.Outbound); + }); AddPolicyGroupServerCmd = ReactiveCommand.CreateFromTask(async () => { await AddServerAsync(EConfigType.PolicyGroup); @@ -432,7 +437,7 @@ public partial class MainWindowViewModel : MyReactiveObject }; bool? ret = false; - if (eConfigType == EConfigType.Custom) + if (eConfigType is EConfigType.Custom or EConfigType.Outbound) { var addServer2ViewModel = new AddServer2ViewModel(item); ret = await AppManager.Instance.WindowDialog.ShowDialogAsync(addServer2ViewModel); diff --git a/v2rayN/ServiceLib/ViewModels/ProfilesViewModel.cs b/v2rayN/ServiceLib/ViewModels/ProfilesViewModel.cs index 6771b22e..42b17c35 100644 --- a/v2rayN/ServiceLib/ViewModels/ProfilesViewModel.cs +++ b/v2rayN/ServiceLib/ViewModels/ProfilesViewModel.cs @@ -503,7 +503,7 @@ public partial class ProfilesViewModel : MyReactiveObject var eConfigType = item.ConfigType; bool? ret = false; - if (eConfigType == EConfigType.Custom) + if (eConfigType is EConfigType.Custom or EConfigType.Outbound) { var addServer2ViewModel = new AddServer2ViewModel(item); ret = await AppManager.Instance.WindowDialog.ShowDialogAsync(addServer2ViewModel); diff --git a/v2rayN/ServiceLib/ViewModels/SubEditViewModel.cs b/v2rayN/ServiceLib/ViewModels/SubEditViewModel.cs index 610f6082..5db7f195 100644 --- a/v2rayN/ServiceLib/ViewModels/SubEditViewModel.cs +++ b/v2rayN/ServiceLib/ViewModels/SubEditViewModel.cs @@ -7,6 +7,9 @@ public partial class SubEditViewModel : MyReactiveObject, ICloseable [Reactive] public partial SubItem SelectedSource { get; set; } + [Reactive] + public partial string CustomCoreType { get; set; } + public ReactiveCommand SelectPrevProfileCmd { get; } public ReactiveCommand SelectNextProfileCmd { get; } public ReactiveCommand SaveCmd { get; } @@ -39,6 +42,7 @@ public partial class SubEditViewModel : MyReactiveObject, ICloseable }); SelectedSource = subItem.Id.IsNullOrEmpty() ? subItem : JsonUtils.DeepCopy(subItem); + CustomCoreType = SelectedSource.CustomCoreType?.ToString() ?? string.Empty; } private async Task SaveSubAsync() @@ -67,6 +71,8 @@ public partial class SubEditViewModel : MyReactiveObject, ICloseable } } + SelectedSource.CustomCoreType = Enum.TryParse(CustomCoreType, out var coreType) ? coreType : null; + if (await ConfigHandler.AddSubItem(_config, SelectedSource) == 0) { NoticeManager.Instance.Enqueue(ResUI.OperationSuccess); diff --git a/v2rayN/v2rayN.Desktop/Views/AddServer2Window.axaml b/v2rayN/v2rayN.Desktop/Views/AddServer2Window.axaml index d08e85f6..07cc80c4 100644 --- a/v2rayN/v2rayN.Desktop/Views/AddServer2Window.axaml +++ b/v2rayN/v2rayN.Desktop/Views/AddServer2Window.axaml @@ -103,57 +103,102 @@ HorizontalAlignment="Left" MaxDropDownHeight="1000" /> - - + Grid.ColumnSpan="3" + Margin="{StaticResource Margin4}" /> + + + + + + + + + + + + + + + + + + + - - - - - - - - + diff --git a/v2rayN/v2rayN.Desktop/Views/AddServer2Window.axaml.cs b/v2rayN/v2rayN.Desktop/Views/AddServer2Window.axaml.cs index 651b0d0f..1303b116 100644 --- a/v2rayN/v2rayN.Desktop/Views/AddServer2Window.axaml.cs +++ b/v2rayN/v2rayN.Desktop/Views/AddServer2Window.axaml.cs @@ -12,15 +12,19 @@ public partial class AddServer2Window : WindowBase Loaded += Window_Loaded; btnCancel.Click += (s, e) => Close(); - cmbCoreType.ItemsSource = Utils.GetEnumNames().Where(t => t != nameof(ECoreType.v2rayN)).ToList().AppendEmpty(); - this.WhenActivated(disposables => { + this.WhenAnyValue(v => v.ViewModel.SelectedSource) + .KeepNotNull() + .Subscribe(InitializeData) + .DisposeWith(disposables); + this.Bind(ViewModel, vm => vm.SelectedSource.Remarks, v => v.txtRemarks.Text).DisposeWith(disposables); this.Bind(ViewModel, vm => vm.SelectedSource.Address, v => v.txtAddress.Text).DisposeWith(disposables); this.Bind(ViewModel, vm => vm.CoreType, v => v.cmbCoreType.SelectedValue).DisposeWith(disposables); this.Bind(ViewModel, vm => vm.SelectedSource.DisplayLog, v => v.togDisplayLog.IsChecked).DisposeWith(disposables); this.Bind(ViewModel, vm => vm.SelectedSource.PreSocksPort, v => v.txtPreSocksPort.Text).DisposeWith(disposables); + this.Bind(ViewModel, vm => vm.IsSingboxEndpoint, v => v.togSingBoxEndpoint.IsChecked).DisposeWith(disposables); this.BindCommand(ViewModel, vm => vm.BrowseServerCmd, v => v.btnBrowse).DisposeWith(disposables); this.BindCommand(ViewModel, vm => vm.EditServerCmd, v => v.btnEdit).DisposeWith(disposables); @@ -34,6 +38,24 @@ public partial class AddServer2Window : WindowBase }); } + private void InitializeData(ProfileItem profileItem) + { + if (profileItem.ConfigType is EConfigType.Custom) + { + Title = ResUI.menuAddCustomServer; + cmbCoreType.ItemsSource = Utils.GetEnumNames().Where(t => t != nameof(ECoreType.v2rayN)).ToList(); + gridCustomServer.IsVisible = true; + gridCustomOutbound.IsVisible = false; + } + else if (profileItem.ConfigType is EConfigType.Outbound) + { + Title = ResUI.menuAddCustomOutboundServer; + cmbCoreType.ItemsSource = Global.CoreTypes; + gridCustomServer.IsVisible = false; + gridCustomOutbound.IsVisible = true; + } + } + private void Window_Loaded(object? sender, RoutedEventArgs e) { txtRemarks.Focus(); diff --git a/v2rayN/v2rayN.Desktop/Views/MainWindow.axaml b/v2rayN/v2rayN.Desktop/Views/MainWindow.axaml index c9bd05cf..21022554 100644 --- a/v2rayN/v2rayN.Desktop/Views/MainWindow.axaml +++ b/v2rayN/v2rayN.Desktop/Views/MainWindow.axaml @@ -33,9 +33,11 @@ Header="{x:Static resx:ResUI.menuAddServerViaScan}" InputGesture="Ctrl+S" /> + + diff --git a/v2rayN/v2rayN.Desktop/Views/MainWindow.axaml.cs b/v2rayN/v2rayN.Desktop/Views/MainWindow.axaml.cs index 1013e07b..a4bb644a 100644 --- a/v2rayN/v2rayN.Desktop/Views/MainWindow.axaml.cs +++ b/v2rayN/v2rayN.Desktop/Views/MainWindow.axaml.cs @@ -47,6 +47,7 @@ public partial class MainWindow : WindowBase this.BindCommand(ViewModel, vm => vm.AddAnytlsServerCmd, v => v.menuAddAnytlsServer).DisposeWith(disposables); this.BindCommand(ViewModel, vm => vm.AddNaiveServerCmd, v => v.menuAddNaiveServer).DisposeWith(disposables); this.BindCommand(ViewModel, vm => vm.AddCustomServerCmd, v => v.menuAddCustomServer).DisposeWith(disposables); + this.BindCommand(ViewModel, vm => vm.AddCustomOutboundServerCmd, v => v.menuAddCustomOutboundServer).DisposeWith(disposables); this.BindCommand(ViewModel, vm => vm.AddPolicyGroupServerCmd, v => v.menuAddPolicyGroupServer).DisposeWith(disposables); this.BindCommand(ViewModel, vm => vm.AddProxyChainServerCmd, v => v.menuAddProxyChainServer).DisposeWith(disposables); this.BindCommand(ViewModel, vm => vm.AddServerViaClipboardCmd, v => v.menuAddServerViaClipboard).DisposeWith(disposables); diff --git a/v2rayN/v2rayN.Desktop/Views/SubEditWindow.axaml b/v2rayN/v2rayN.Desktop/Views/SubEditWindow.axaml index 40309e5c..77f7d997 100644 --- a/v2rayN/v2rayN.Desktop/Views/SubEditWindow.axaml +++ b/v2rayN/v2rayN.Desktop/Views/SubEditWindow.axaml @@ -34,7 +34,7 @@ - + + PlaceholderText="{x:Static resx:ResUI.SubUrlTips}" + TextWrapping="Wrap" />