diff --git a/v2rayN/v2rayN.WebAPI/Launcher/WebLaunchOptions.cs b/v2rayN/v2rayN.WebAPI/Launcher/WebLaunchOptions.cs index 60a02cc5..1a137cef 100644 --- a/v2rayN/v2rayN.WebAPI/Launcher/WebLaunchOptions.cs +++ b/v2rayN/v2rayN.WebAPI/Launcher/WebLaunchOptions.cs @@ -109,7 +109,8 @@ public static class LauncherEnvironment public static Dictionary ReadCurrentEnvironment() => Environment.GetEnvironmentVariables() .Cast() - .ToDictionary(entry => (string)entry.Key, entry => entry.Value?.ToString(), StringComparer.OrdinalIgnoreCase); + .ToDictionary(entry => (string)entry.Key, entry => entry.Value?.ToString(), + OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal); private static bool HasValue(IReadOnlyDictionary environment, string key) => environment.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value); diff --git a/v2rayN/v2rayN.WebAPI/Tests/CoreProcessTrackerTests.cs b/v2rayN/v2rayN.WebAPI/Tests/CoreProcessTrackerTests.cs index 202319d1..d1520c5f 100644 --- a/v2rayN/v2rayN.WebAPI/Tests/CoreProcessTrackerTests.cs +++ b/v2rayN/v2rayN.WebAPI/Tests/CoreProcessTrackerTests.cs @@ -178,7 +178,8 @@ public class CoreProcessTrackerTests private static string CopySleepExecutable(string directory) { - var executable = Path.Combine(directory, "sleep-core-test"); + // Multicall implementations dispatch by basename, so preserve the sleep utility name. + var executable = Path.Combine(directory, "sleep"); File.Copy("/bin/sleep", executable); return executable; } diff --git a/v2rayN/v2rayN.WebAPI/Tests/LauncherEnvironmentTests.cs b/v2rayN/v2rayN.WebAPI/Tests/LauncherEnvironmentTests.cs new file mode 100644 index 00000000..da6b8140 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/LauncherEnvironmentTests.cs @@ -0,0 +1,100 @@ +using System.Collections; +using v2rayN.WebAPI.Launcher; + +namespace v2rayN.WebAPI.Tests; + +[NotInParallel] +public class LauncherEnvironmentTests +{ + [Test] + [Arguments("same-value", "same-value")] + [Arguments("upper-value", "lower-value")] + public async Task CurrentEnvironmentPreservesPlatformVariableNameSemantics(string upperValue, string lowerValue) + { + var upperName = $"V2RAYN_ENVIRONMENT_TEST_{Guid.NewGuid():N}".ToUpperInvariant(); + var lowerName = upperName.ToLowerInvariant(); + using var environmentScope = new TemporaryEnvironment((upperName, upperValue), (lowerName, lowerValue)); + + var environment = LauncherEnvironment.ReadCurrentEnvironment(); + + await environment[upperName].Should().BeEqualTo(OperatingSystem.IsWindows() ? lowerValue : upperValue); + await environment[lowerName].Should().BeEqualTo(lowerValue); + await environment.Keys.Count(key => string.Equals(key, upperName, StringComparison.OrdinalIgnoreCase)) + .Should().BeEqualTo(OperatingSystem.IsWindows() ? 1 : 2); + } + + [Test] + [Arguments("V2RAYN_WEB_API_KEY")] + [Arguments("LC_ALL")] + public async Task CurrentEnvironmentRequiresCanonicalSettingNamesOnUnix(string canonicalName) + { + var lowerName = canonicalName.ToLowerInvariant(); + using var environmentScope = new TemporaryEnvironment((canonicalName, null), (lowerName, "wrong-case-value")); + + var environment = LauncherEnvironment.ReadCurrentEnvironment(); + + await environment.GetValueOrDefault(canonicalName).Should() + .BeEqualTo(OperatingSystem.IsWindows() ? "wrong-case-value" : null); + await environment[lowerName].Should().BeEqualTo("wrong-case-value"); + } + + [Test] + public async Task CurrentEnvironmentKeepsCanonicalManagementKeyDistinctOnUnix() + { + using var environmentScope = new TemporaryEnvironment( + ("V2RAYN_WEB_API_KEY", "canonical-management-key-123456"), + ("v2rayn_web_api_key", "other-management-key-123456")); + + var environment = LauncherEnvironment.ReadCurrentEnvironment(); + + await environment.GetValueOrDefault("V2RAYN_WEB_API_KEY").Should() + .BeEqualTo(OperatingSystem.IsWindows() ? "other-management-key-123456" : "canonical-management-key-123456"); + await environment["v2rayn_web_api_key"].Should().BeEqualTo("other-management-key-123456"); + } + + [Test] + public async Task CurrentEnvironmentUsesCanonicalInteractiveLaunchMarkerOnUnix() + { + using var environmentScope = new TemporaryEnvironment( + (LauncherEnvironment.InteractiveLaunchEnvironmentVariable, null), + (LauncherEnvironment.InteractiveLaunchEnvironmentVariable.ToLowerInvariant(), "1")); + + var environment = LauncherEnvironment.ReadCurrentEnvironment(); + + await LauncherEnvironment.IsInteractiveLaunchMarker(environment).Should().BeEqualTo(OperatingSystem.IsWindows()); + } + + private sealed class TemporaryEnvironment : IDisposable + { + private readonly string[] _names; + private readonly (string Name, string? Value)[] _originalValues; + + public TemporaryEnvironment(params (string Name, string? Value)[] values) + { + _names = values.Select(value => value.Name).ToArray(); + var comparer = OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; + _originalValues = Environment.GetEnvironmentVariables() + .Cast() + .Where(entry => _names.Contains((string)entry.Key, comparer)) + .Select(entry => ((string)entry.Key, entry.Value?.ToString())) + .ToArray(); + + foreach (var (name, value) in values) + { + Environment.SetEnvironmentVariable(name, value); + } + } + + public void Dispose() + { + foreach (var name in _names) + { + Environment.SetEnvironmentVariable(name, null); + } + foreach (var (name, value) in _originalValues) + { + Environment.SetEnvironmentVariable(name, value); + } + } + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/WebAuthEndpointTests.cs b/v2rayN/v2rayN.WebAPI/Tests/WebAuthEndpointTests.cs index dfc22c2f..35c2bca6 100644 --- a/v2rayN/v2rayN.WebAPI/Tests/WebAuthEndpointTests.cs +++ b/v2rayN/v2rayN.WebAPI/Tests/WebAuthEndpointTests.cs @@ -495,7 +495,8 @@ public class WebAuthEndpointTests var client = new HttpClient(new SocketsHttpHandler { UseProxy = false }) { BaseAddress = new Uri(addresses.Single()), - Timeout = TimeSpan.FromSeconds(5), + // Allow headroom for real authentication work under parallel test load. + Timeout = TimeSpan.FromSeconds(20), }; return new ApiHarness(app, client, app.Services.GetRequiredService()); } diff --git a/v2rayN/v2rayN.WebAPI/Tests/WebAuthRateLimitTests.cs b/v2rayN/v2rayN.WebAPI/Tests/WebAuthRateLimitTests.cs index 754a6e32..d280207a 100644 --- a/v2rayN/v2rayN.WebAPI/Tests/WebAuthRateLimitTests.cs +++ b/v2rayN/v2rayN.WebAPI/Tests/WebAuthRateLimitTests.cs @@ -34,9 +34,7 @@ public class WebAuthRateLimitTests await app.StartAsync(); try { - var server = app.Services.GetRequiredService(); - var address = server.Features.Get()!.Addresses.Single(); - using var client = new HttpClient { BaseAddress = new Uri(address), Timeout = TimeSpan.FromSeconds(20) }; + using var client = CreateClient(app); var statuses = new List(); int? retryAfterSeconds = null; @@ -118,6 +116,38 @@ public class WebAuthRateLimitTests } } + [Test] + [NotInParallel] + public async Task LocalRateLimitRequestsDoNotUseTheDefaultProxy() + { + var proxyBuilder = WebApplication.CreateBuilder(); + proxyBuilder.WebHost.ConfigureKestrel(options => options.Listen(IPAddress.Loopback, 0)); + await using var proxy = proxyBuilder.Build(); + proxy.Run(context => + { + context.Response.StatusCode = StatusCodes.Status502BadGateway; + return Task.CompletedTask; + }); + await proxy.StartAsync(); + await using var app = CreatePartitionTestApp(); + await app.StartAsync(); + var proxyAddress = proxy.Services.GetRequiredService() + .Features.Get()!.Addresses.Single(); + var previousProxy = HttpClient.DefaultProxy; + try + { + HttpClient.DefaultProxy = new FixedProxy(new Uri(proxyAddress)); + using var client = CreateClient(app); + await (await GetLimitedStatusAsync(client, "/setup-limited", "192.0.2.1", 0)) + .Should().BeEqualTo(HttpStatusCode.OK); + } + finally + { + HttpClient.DefaultProxy = previousProxy; + await app.StopAsync(); + await proxy.StopAsync(); + } + } private static WebApplication CreatePartitionTestApp() { var builder = WebApplication.CreateBuilder(); @@ -149,7 +179,11 @@ public class WebAuthRateLimitTests { var server = app.Services.GetRequiredService(); var address = server.Features.Get()!.Addresses.Single(); - return new HttpClient { BaseAddress = new Uri(address), Timeout = TimeSpan.FromSeconds(20) }; + return new HttpClient(new SocketsHttpHandler { UseProxy = false }) + { + BaseAddress = new Uri(address), + Timeout = TimeSpan.FromSeconds(20), + }; } private static async Task GetLimitedStatusAsync(HttpClient client, string endpoint, string remoteIp, int requestIndex) @@ -163,6 +197,12 @@ public class WebAuthRateLimitTests return response.StatusCode; } + private sealed class FixedProxy(Uri address) : IWebProxy + { + public ICredentials? Credentials { get; set; } + public Uri GetProxy(Uri destination) => address; + public bool IsBypassed(Uri host) => false; + } private sealed class TemporaryDirectory : IDisposable { public TemporaryDirectory() diff --git a/v2rayN/v2rayN.WebAPI/Tests/WebLauncherTests.cs b/v2rayN/v2rayN.WebAPI/Tests/WebLauncherTests.cs index 8771e9bf..1b292ca8 100644 --- a/v2rayN/v2rayN.WebAPI/Tests/WebLauncherTests.cs +++ b/v2rayN/v2rayN.WebAPI/Tests/WebLauncherTests.cs @@ -4,6 +4,10 @@ namespace v2rayN.WebAPI.Tests; public class WebLauncherTests { + // Keep fixture PIDs outside Linux's assignable range so host services cannot affect stop tests. + private const int FakeOwnerProcessId = int.MaxValue - 1; + private const int OtherProcessId = int.MaxValue; + [Test] public async Task InstanceLockOwnerCanBeReadWhileAnotherProcessHoldsTheLock() { @@ -228,13 +232,13 @@ public class WebLauncherTests using var directory = new TemporaryDirectory(); var lockPath = Path.Combine(directory.Path, "instance.lock"); - await File.WriteAllTextAsync(lockPath, "123\n"); + await File.WriteAllTextAsync(lockPath, $"{FakeOwnerProcessId}\n"); var signals = new FakeSignalSender(); - var stopper = new WebStopper(new FakeHealthProbe(true, 123), signals); + var stopper = new WebStopper(new FakeHealthProbe(true, FakeOwnerProcessId), signals); var result = await stopper.StopAsync(lockPath, new Uri("http://127.0.0.1:5080/api/health")); - await (result == WebStopResult.NotRunning).Should().BeTrue(); + await result.Should().BeEqualTo(WebStopResult.NotRunning); await (signals.ProcessIds.Count == 0).Should().BeTrue(); } @@ -245,11 +249,11 @@ public class WebLauncherTests using var directory = new TemporaryDirectory(); var lockPath = Path.Combine(directory.Path, "instance.lock"); - var heldLock = await AcquireLockWithOwnerAsync(lockPath, 123); + var heldLock = await AcquireLockWithOwnerAsync(lockPath, FakeOwnerProcessId); using (heldLock) { var signals = new FakeSignalSender(); - var health = new FakeHealthProbe(true, 123); + var health = new FakeHealthProbe(true, FakeOwnerProcessId); signals.OnSignal = processId => { _ = ReleaseLockAfterDelayAsync(heldLock, health); @@ -262,8 +266,8 @@ public class WebLauncherTests var result = await stopper.StopAsync(lockPath, new Uri("http://127.0.0.1:5080/api/health")); - await (result == WebStopResult.Stopped).Should().BeTrue(); - await signals.ProcessIds.SequenceEqual([123]).Should().BeTrue(); + await result.Should().BeEqualTo(WebStopResult.Stopped); + await signals.ProcessIds.SequenceEqual([FakeOwnerProcessId]).Should().BeTrue(); await (health.ProbeCount >= 2).Should().BeTrue(); } } @@ -282,18 +286,18 @@ public class WebLauncherTests using var directory = new TemporaryDirectory(); var lockPath = Path.Combine(directory.Path, "instance.lock"); - var heldLock = await AcquireLockWithOwnerAsync(lockPath, 123); + var heldLock = await AcquireLockWithOwnerAsync(lockPath, FakeOwnerProcessId); using (heldLock) { - var health = new FakeHealthProbe(true, 123, [Environment.ProcessId]); + var health = new FakeHealthProbe(true, FakeOwnerProcessId, [Environment.ProcessId]); var signals = new FakeSignalSender(); signals.OnSignal = processId => _ = ReleaseLockAfterDelayAsync(heldLock, health); var stopper = new WebStopper(health, signals, TimeSpan.FromMilliseconds(500), TimeSpan.FromMilliseconds(10)); var result = await stopper.StopAsync(lockPath, new Uri("http://127.0.0.1:5080/api/health")); - await (result == WebStopResult.CoreProcessStillRunning).Should().BeTrue(); - await signals.ProcessIds.SequenceEqual([123]).Should().BeTrue(); + await result.Should().BeEqualTo(WebStopResult.CoreProcessStillRunning); + await signals.ProcessIds.SequenceEqual([FakeOwnerProcessId]).Should().BeTrue(); } } @@ -304,13 +308,13 @@ public class WebLauncherTests using var directory = new TemporaryDirectory(); var lockPath = Path.Combine(directory.Path, "instance.lock"); - using var heldLock = await AcquireLockWithOwnerAsync(lockPath, 123); + using var heldLock = await AcquireLockWithOwnerAsync(lockPath, FakeOwnerProcessId); var signals = new FakeSignalSender(); - var stopper = new WebStopper(new FakeHealthProbe(true, 456), signals); + var stopper = new WebStopper(new FakeHealthProbe(true, OtherProcessId), signals); var result = await stopper.StopAsync(lockPath, new Uri("http://127.0.0.1:5080/api/health")); - await (result == WebStopResult.IdentityUnverified).Should().BeTrue(); + await result.Should().BeEqualTo(WebStopResult.IdentityUnverified); await (signals.ProcessIds.Count == 0).Should().BeTrue(); } @@ -321,13 +325,13 @@ public class WebLauncherTests using var directory = new TemporaryDirectory(); var lockPath = Path.Combine(directory.Path, "instance.lock"); - using var heldLock = await AcquireLockWithOwnerAsync(lockPath, 123); + using var heldLock = await AcquireLockWithOwnerAsync(lockPath, FakeOwnerProcessId); var signals = new FakeSignalSender(); - var stopper = new WebStopper(new FakeHealthProbe(false, 123), signals); + var stopper = new WebStopper(new FakeHealthProbe(false, FakeOwnerProcessId), signals); var result = await stopper.StopAsync(lockPath, new Uri("http://127.0.0.1:5080/api/health")); - await (result == WebStopResult.IdentityUnverified).Should().BeTrue(); + await result.Should().BeEqualTo(WebStopResult.IdentityUnverified); await (signals.ProcessIds.Count == 0).Should().BeTrue(); } @@ -338,18 +342,18 @@ public class WebLauncherTests using var directory = new TemporaryDirectory(); var lockPath = Path.Combine(directory.Path, "instance.lock"); - using var heldLock = await AcquireLockWithOwnerAsync(lockPath, 123); + using var heldLock = await AcquireLockWithOwnerAsync(lockPath, FakeOwnerProcessId); var signals = new FakeSignalSender(); - var stopper = new WebStopper(new FakeHealthProbe(true, 123), signals); + var stopper = new WebStopper(new FakeHealthProbe(true, FakeOwnerProcessId), signals); var result = await stopper.StopAsync(lockPath, healthUri: null); - await (result == WebStopResult.ProbeUnavailable).Should().BeTrue(); + await result.Should().BeEqualTo(WebStopResult.ProbeUnavailable); await (signals.ProcessIds.Count == 0).Should().BeTrue(); var notRunning = await stopper.StopAsync( Path.Combine(directory.Path, "missing-instance.lock"), healthUri: null); - await (notRunning == WebStopResult.NotRunning).Should().BeTrue(); + await notRunning.Should().BeEqualTo(WebStopResult.NotRunning); } [Test] @@ -359,18 +363,18 @@ public class WebLauncherTests using var directory = new TemporaryDirectory(); var lockPath = Path.Combine(directory.Path, "instance.lock"); - using var heldLock = await AcquireLockWithOwnerAsync(lockPath, 123); + using var heldLock = await AcquireLockWithOwnerAsync(lockPath, FakeOwnerProcessId); var signals = new FakeSignalSender(); var stopper = new WebStopper( - new FakeHealthProbe(true, 123), + new FakeHealthProbe(true, FakeOwnerProcessId), signals, stopTimeout: TimeSpan.FromMilliseconds(60), pollInterval: TimeSpan.FromMilliseconds(5)); var result = await stopper.StopAsync(lockPath, new Uri("http://127.0.0.1:5080/api/health")); - await (result == WebStopResult.TimedOut).Should().BeTrue(); - await signals.ProcessIds.SequenceEqual([123]).Should().BeTrue(); + await result.Should().BeEqualTo(WebStopResult.TimedOut); + await signals.ProcessIds.SequenceEqual([FakeOwnerProcessId]).Should().BeTrue(); await WebInstanceLock.IsHeld(lockPath).Should().BeTrue(); } @@ -381,8 +385,8 @@ public class WebLauncherTests using var directory = new TemporaryDirectory(); var lockPath = Path.Combine(directory.Path, "instance.lock"); - using var heldLock = await AcquireLockWithOwnerAsync(lockPath, 123); - var health = new FakeHealthProbe(true, 123); + using var heldLock = await AcquireLockWithOwnerAsync(lockPath, FakeOwnerProcessId); + var health = new FakeHealthProbe(true, FakeOwnerProcessId); health.SetShutdownStage("Core stop"); var stopper = new WebStopper( health, @@ -392,7 +396,7 @@ public class WebLauncherTests var result = await stopper.StopAsync(lockPath, new Uri("http://127.0.0.1:5080/api/health")); - await (result == WebStopResult.TimedOut).Should().BeTrue(); + await result.Should().BeEqualTo(WebStopResult.TimedOut); await (stopper.LastObservedShutdownStage == "Core stop").Should().BeTrue(); await LauncherMessages.StopMessage(result, LauncherLocale.English, stopper.LastObservedShutdownStage) .Contains("Core stop", StringComparison.Ordinal).Should().BeTrue();