diff --git a/v2rayN/ServiceLib.Tests/CoreConfig/Singbox/CoreConfigSingboxServiceTests.cs b/v2rayN/ServiceLib.Tests/CoreConfig/Singbox/CoreConfigSingboxServiceTests.cs index 10b89551..96b4c600 100644 --- a/v2rayN/ServiceLib.Tests/CoreConfig/Singbox/CoreConfigSingboxServiceTests.cs +++ b/v2rayN/ServiceLib.Tests/CoreConfig/Singbox/CoreConfigSingboxServiceTests.cs @@ -592,4 +592,38 @@ public class CoreConfigSingboxServiceTests proxy.realm.stun_servers.Should().Contain("turn.cloudflare.com:3478"); proxy.server.Should().BeNull(); } + + [Fact] + public void GenerateClientConfigContent_TunSystemStackWithIpv6_ShouldUsePrefixWithPeerAddress() + { + // Regression test for #9820: sing-box fails with "need one more IPv6 address in + // first prefix for system stack" when the TUN inbound uses a /128 IPv6 prefix. + var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box); + config.TunModeItem.EnableTun = true; + config.TunModeItem.Stack = "system"; + config.TunModeItem.EnableIPv6Address = 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())!; + var tun = cfg.inbounds.First(i => i.type == "tun"); + + tun.address.Should().NotBeNullOrEmpty(); + foreach (var address in tun.address!) + { + var prefixLength = int.Parse(address[(address.LastIndexOf('/') + 1)..]); + var isIpv6 = address.Contains(':'); + prefixLength.Should().BeLessThanOrEqualTo(isIpv6 ? 126 : 30, + $"'{address}' must leave room for the peer address the system stack derives"); + } + } + } diff --git a/v2rayN/ServiceLib.Tests/Manager/CoreManagerTests.cs b/v2rayN/ServiceLib.Tests/Manager/CoreManagerTests.cs new file mode 100644 index 00000000..d4135758 --- /dev/null +++ b/v2rayN/ServiceLib.Tests/Manager/CoreManagerTests.cs @@ -0,0 +1,44 @@ +using AwesomeAssertions; +using ServiceLib.Enums; +using ServiceLib.Manager; +using Xunit; + +namespace ServiceLib.Tests.Manager; + +public class CoreManagerTests +{ + [Theory] + [InlineData(ECoreType.sing_box)] + [InlineData(ECoreType.mihomo)] + [InlineData(ECoreType.Xray)] + public void ShouldRunAsSudo_TunLaunchOnNonWindows_RequiresElevation(ECoreType coreType) + { + CoreManager.ShouldRunAsSudo(isTunLaunch: true, coreType, isNonWindows: true).Should().BeTrue(); + } + + [Fact] + public void ShouldRunAsSudo_NonTunLaunch_ShouldNotElevate() + { + // Regression guard for the macOS TUN failure: the elevation decision must follow + // the context snapshot that generated the config. A launch whose snapshot has TUN + // disabled must never elevate, and a launch whose snapshot has TUN enabled must + // elevate regardless of later changes to the live config. + CoreManager.ShouldRunAsSudo(isTunLaunch: false, ECoreType.sing_box, isNonWindows: true).Should().BeFalse(); + CoreManager.ShouldRunAsSudo(isTunLaunch: false, ECoreType.Xray, isNonWindows: true).Should().BeFalse(); + } + + [Fact] + public void ShouldRunAsSudo_OnWindows_ShouldNotElevate() + { + CoreManager.ShouldRunAsSudo(isTunLaunch: true, ECoreType.sing_box, isNonWindows: false).Should().BeFalse(); + } + + [Theory] + [InlineData(ECoreType.v2fly)] + [InlineData(ECoreType.hysteria)] + [InlineData(null)] + public void ShouldRunAsSudo_UnsupportedCoreType_ShouldNotElevate(ECoreType? coreType) + { + CoreManager.ShouldRunAsSudo(isTunLaunch: true, coreType, isNonWindows: true).Should().BeFalse(); + } +} diff --git a/v2rayN/ServiceLib/Manager/CoreManager.cs b/v2rayN/ServiceLib/Manager/CoreManager.cs index 7610673c..04ac4f3c 100644 --- a/v2rayN/ServiceLib/Manager/CoreManager.cs +++ b/v2rayN/ServiceLib/Manager/CoreManager.cs @@ -183,7 +183,7 @@ public class CoreManager var coreInfo = CoreInfoManager.Instance.GetCoreInfo(coreType); var displayLog = node.ConfigType != EConfigType.Custom || node.DisplayLog; - var proc = await RunProcess(coreInfo, Global.CoreConfigFileName, displayLog, true); + var proc = await RunProcess(coreInfo, Global.CoreConfigFileName, displayLog, true, context.IsTunEnabled); if (proc is null) { return; @@ -201,7 +201,7 @@ public class CoreManager if (result.Success) { var coreInfo = CoreInfoManager.Instance.GetCoreInfo(preCoreType); - var proc = await RunProcess(coreInfo, Global.CorePreConfigFileName, true, true); + var proc = await RunProcess(coreInfo, Global.CorePreConfigFileName, true, true, preContext.IsTunEnabled); if (proc is null) { return; @@ -289,7 +289,20 @@ public class CoreManager #region Process - private async Task RunProcess(CoreInfo? coreInfo, string configPath, bool displayLog, bool mayNeedSudo) + /// + /// Decides whether a core launch must be elevated on non-Windows platforms. + /// The TUN state comes from the immutable snapshot that + /// generated the config, never from the live mutable config: the generated config and the + /// launch mode must always agree, even if TUN is toggled while a reload is in flight. + /// + public static bool ShouldRunAsSudo(bool isTunLaunch, ECoreType? coreType, bool isNonWindows) + { + return isTunLaunch + && coreType is ECoreType.sing_box or ECoreType.mihomo or ECoreType.Xray + && isNonWindows; + } + + private async Task RunProcess(CoreInfo? coreInfo, string configPath, bool displayLog, bool mayNeedSudo, bool isTunLaunch = false) { var fileName = CoreInfoManager.Instance.GetCoreExecFile(coreInfo, out var msg); if (fileName.IsNullOrEmpty()) @@ -301,9 +314,7 @@ public class CoreManager try { if (mayNeedSudo - && _config.TunModeItem.EnableTun - && (coreInfo.CoreType is ECoreType.sing_box or ECoreType.mihomo or ECoreType.Xray) - && Utils.IsNonWindows()) + && ShouldRunAsSudo(isTunLaunch, coreInfo.CoreType, Utils.IsNonWindows())) { _linuxSudo = true; await CoreAdminManager.Instance.Init(_config, _updateFunc);