Files
Курнат Андрей aae6dd28dd Release QVPN 1.0.5 with Xray and resilient tunnel recovery
Integrate Xray 26.9.9 with the sing-box system tunnel and verify Windows routes during health checks.

Improve endpoint selection, network recovery, subscription refresh deadlines, diagnostics, packaging, and regression coverage.
2026-10-30 06:15:08 +03:00

463 lines
31 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Buffers.Binary;
using System.Diagnostics;
using System.Net;
using System.Net.Security;
using System.Net.Sockets;
using System.IO.Pipes;
using System.Security.AccessControl;
using System.Security.Cryptography;
using System.Security.Principal;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using QVPN.Core;
// All fixtures are generated locally. No real subscription, server credential, or key is in this file.
return await Runner.MainAsync(args);
static class Runner
{
private static int passed;
private static int failed;
private const int MaxWireSize = 256 * 1024;
private static readonly string FakeUuid = new Guid(Enumerable.Range(1, 16).Select(x => (byte)x).ToArray()).ToString();
private static readonly string FakePublicKey = Convert.ToBase64String(Enumerable.Range(1, 32).Select(x => (byte)x).ToArray()).TrimEnd('=').Replace('+', '-').Replace('/', '_');
private static readonly string FakeUrl = "https://test.withblancvpn.online/s/" + new string('a', 32);
public static async Task<int> MainAsync(string[] args)
{
if (args.Length > 0) return await CommandAsync(args);
await Test("Blanc URL accepted", () => { Check(Subscription.ValidateUrl(FakeUrl).Scheme == "https"); });
await Test("Foreign provider and suffix spoof rejected", () =>
{
foreach (var url in new[] { "https://example.org/s/", "https://withblancvpn.online.example.org/s/", "https://evilwithblancvpn.online/s/" })
Throws<ArgumentException>(() => Subscription.ValidateUrl(url + new string('a', 32)));
});
await Test("Unicode and punycode provider spoof rejected", () =>
{
var spoof = new Uri("https://withblаncvpn.online/s/" + new string('a', 32)); // Cyrillic a, not the provider.
Throws<ArgumentException>(() => Subscription.ValidateUrl(spoof.AbsoluteUri));
Throws<ArgumentException>(() => Subscription.ValidateUrl("https://" + spoof.IdnHost + spoof.AbsolutePath));
});
await Test("Unsafe URL components rejected", () =>
{
foreach (var url in new[] { FakeUrl.Replace("https:", "http:"), FakeUrl.Replace("https://", "https://name:secret@"), FakeUrl + "?token=x", FakeUrl + "#fragment", FakeUrl.Replace(".online/", ".online:444/"), FakeUrl.Replace("/s/", "/other/"), FakeUrl.Replace("/s/", "/s/%2f"), FakeUrl[..^20] })
Throws<ArgumentException>(() => Subscription.ValidateUrl(url));
});
await Test("Realistic 50-node plain and base64 subscriptions", () =>
{
var text = string.Join('\n', Enumerable.Range(0, 50).Select(i => Link("8.8.4.4", 10000 + i, "Location " + i)));
Check(Subscription.Parse(text).Count == 50);
Check(Subscription.Parse(Convert.ToBase64String(Encoding.UTF8.GetBytes(text))).Count == 50);
Check(Subscription.Parse("\uFEFF\r\n " + text + "\r\n").Count == 50);
});
await Test("Duplicate nodes deduplicated; names sanitized", () =>
{
var first = Link(name: "Safe%0AName");
var nodes = Subscription.Parse(first + "\n" + Link(name: "Other name"));
Check(nodes.Count == 1 && nodes[0].Name == "SafeName");
Check(Subscription.Parse(Link(name: new string('a', 200)))[0].Name.Length == 160);
});
await Test("Unsupported protocols and malformed Reality rejected", () =>
{
var valid = Link();
foreach (var bad in new[] { "not base64!", valid.Replace("vless://", "vmess://"), valid.Replace("security=reality", "security=tls"), valid.Replace("type=tcp", "type=ws"), valid.Replace("headerType=none", "headerType=http"), valid.Replace("encryption=none", "encryption=aes"), valid.Replace("pbk=" + FakePublicKey, "pbk=short"), valid.Replace("sid=abcd", "sid=abc"), valid.Replace("sni=example.com", "sni=bad%0Ahost"), valid.Replace("fp=chrome", "fp=unknown"), valid.Replace("flow=xtls-rprx-vision", "flow=unknown"), valid.Replace(FakeUuid, "not-a-uuid"), valid.Replace(":443?", ":0?") })
Throws<InvalidDataException>(() => Subscription.Parse(bad));
Throws<InvalidDataException>(() => Subscription.Parse(""));
Throws<InvalidDataException>(() => Subscription.Parse(valid + "\nnot a location"));
Throws<InvalidDataException>(() => Subscription.Parse(string.Join('\n', Enumerable.Range(0, 257).Select(i => Link(port: 10000 + i)))));
});
await Test("Local/private IP server targets rejected", () =>
{
foreach (var host in new[] { "127.0.0.1", "0.0.0.0", "10.0.0.1", "172.16.0.1", "172.31.0.1", "192.168.1.1", "169.254.1.1", "100.64.0.1", "198.18.0.1", "224.0.0.1", "255.255.255.255", "[::1]", "[::]", "[fc00::1]", "[fd12::1]", "[fe80::1]", "[ff02::1]", "[::ffff:127.0.0.1]", "[::ffff:192.168.1.1]", "localhost", "example.com" })
Throws<InvalidDataException>(() => Subscription.Parse(Link(host)));
});
await Test("DPAPI encrypted atomic store and protected ACL", StoreAsync);
await Test("Wire request and status roundtrip", async () =>
{
var request = new Request("SetRussianDirect", Enabled: false);
using var buffer = new MemoryStream();
await Wire.WriteAsync(buffer, request, default); buffer.Position = 0;
Check(await Wire.ReadAsync<Request>(buffer, default) == request);
var status = new Status("Disconnected", "Ready", false, null, null, null, 0, null, true, true, "", null, [], []);
buffer.SetLength(0); buffer.Position = 0;
await Wire.WriteAsync(buffer, new Response(true, null, status), default); buffer.Position = 0;
var read = await Wire.ReadAsync<Response>(buffer, default);
Check(read.Ok && read.Status?.State == "Disconnected" && read.Status.Nodes.Count == 0);
});
await Test("Wire invalid length and truncated payload rejected", async () =>
{
foreach (var length in new[] { 0, -1, MaxWireSize + 1, int.MaxValue })
{
var header = new byte[4]; BinaryPrimitives.WriteInt32LittleEndian(header, length);
using var buffer = new MemoryStream(header);
await ThrowsAsync<InvalidDataException>(() => Wire.ReadAsync<Request>(buffer, default));
}
using (var shortHeader = new MemoryStream(new byte[3])) await ThrowsAsync<EndOfStreamException>(() => Wire.ReadAsync<Request>(shortHeader, default));
var bytes = new byte[5]; BinaryPrimitives.WriteInt32LittleEndian(bytes, 20);
using (var shortPayload = new MemoryStream(bytes)) await ThrowsAsync<EndOfStreamException>(() => Wire.ReadAsync<Request>(shortPayload, default));
using var nullPayload = new MemoryStream();
await Wire.WriteAsync(nullPayload, (Request?)null, default); nullPayload.Position = 0;
await ThrowsAsync<InvalidDataException>(() => Wire.ReadAsync<Request>(nullPayload, default));
var malformedJson = Encoding.UTF8.GetBytes("{not-json}");
var malformedFrame = new byte[4 + malformedJson.Length];
BinaryPrimitives.WriteInt32LittleEndian(malformedFrame, malformedJson.Length);
malformedJson.CopyTo(malformedFrame, 4);
using var malformedPayload = new MemoryStream(malformedFrame);
await ThrowsAsync<JsonException>(() => Wire.ReadAsync<Request>(malformedPayload, default));
});
await Test("Wire outbound bound checked before any write", async () =>
{
using var stream = new MemoryStream();
await ThrowsAsync<InvalidDataException>(() => Wire.WriteAsync(stream, new Request("Import", new string('x', MaxWireSize)), default));
Check(stream.Length == 0);
});
await Test("TUN strict routing, tunneled DNS and proxy health precedence", Config);
await Test("Russian direct toggle and local rule-set wiring", () =>
{
var directory = NewTemporaryDirectory();
try
{
File.WriteAllBytes(Path.Combine(directory, "geoip-ru.srs"), []);
File.WriteAllBytes(Path.Combine(directory, "geosite-category-ru.srs"), []);
var on = ConfigBuilder.Build(Subscription.Parse(Link())[0], false, true, directory);
Check(on["inbounds"]!.AsArray().Count == 1);
Check(on["route"]!["rule_set"]!.AsArray().Count == 2);
Check(on["route"]!["rules"]!.AsArray().Count(r => r?["outbound"]?.GetValue<string>() == "direct") == 4);
var off = ConfigBuilder.Build(Subscription.Parse(Link())[0], true, false, directory);
Check(off["route"]!["rule_set"] is null);
Check(off["route"]!["rules"]!.AsArray().Count(r => r?["outbound"]?.GetValue<string>() == "direct") == 1);
}
finally { RemoveTemporaryDirectory(directory); }
});
await Test("Recovery requires three failures and healthy resets streak", () =>
{
var p = new RecoveryPolicy();
Check(!p.HealthFailed()); Check(!p.HealthFailed()); p.Healthy();
Check(!p.HealthFailed()); Check(!p.HealthFailed()); Check(p.HealthFailed());
Check(p.ConsecutiveFailures == 3); p.Healthy(); Check(p.ConsecutiveFailures == 0);
});
await Test("New endpoint fails quickly; established tunnel tolerates a transient miss", () =>
{
var p = new RecoveryPolicy(); Check(!p.HealthFailed(false)); Check(p.HealthFailed(false));
p.Healthy(); Check(!p.HealthFailed()); Check(!p.HealthFailed()); Check(p.HealthFailed());
});
await Test("Fresh alternatives do not inherit exponential outage backoff", () =>
{
var p = new RecoveryPolicy(); var now = DateTimeOffset.UtcNow;
for (int i = 0; i < 20; i++) { p.Failed(now, true); Check(p.NextAttempt == now.AddMilliseconds(250)); Check(p.Attempts == 0); }
p.Failed(now); Check(p.NextAttempt == now.AddSeconds(2));
p.Failed(now); Check(p.NextAttempt == now.AddSeconds(4));
p.Failed(now, true); Check(p.NextAttempt == now.AddMilliseconds(250));
p.Reset(); Check(p.CanAttempt(now) && p.Attempts == 0);
});
await NodeSelectionTests.RunAsync((name, action) => Test(name, action));
await SubscriptionTransportTests.RunAsync((name, action) => Test(name, action));
await SystemTunnelProbeTests.RunAsync((name, action) => Test(name, action));
await Test("Subscription deadline covers headers and a stalled body", SubscriptionDownloadTests.HeadersDoNotEndBodyDeadlineAsync);
await Test("Caller cancellation wins the subscription body deadline", SubscriptionDownloadTests.CallerCancellationWinsBodyDeadlineAsync);
await Test("Bounded subscription download preserves provider and body validation", SubscriptionDownloadTests.CompletedBodyPreservesValidationAsync);
await XrayConfigTests.RunAsync((name, action) => Test(name, action));
await SubscriptionRefreshPolicyTests.RunAsync((name, action) => Test(name, action));
await Test("System TUN routes all proxy traffic through local Xray without storing credentials", () =>
{
var config = ConfigBuilder.BuildXrayBridge(true, true, Path.GetTempPath());
var proxy = config["outbounds"]!.AsArray().Single(n => n?["tag"]?.GetValue<string>() == "proxy")!;
Check(proxy["type"]!.GetValue<string>() == "socks" && proxy["server"]!.GetValue<string>() == "127.0.0.1");
Check(proxy["server_port"]!.GetValue<int>() == Constants.XrayPort && proxy["version"]!.GetValue<string>() == "5");
Check(!config.ToJsonString().Contains(FakeUuid) && !config.ToJsonString().Contains("public_key"));
Check(config["inbounds"]!.AsArray().Any(n => n?["type"]?.GetValue<string>() == "tun"));
Check(config["dns"]!["servers"]!.AsArray().Single()!["detour"]!.GetValue<string>() == "proxy");
});
await Test("Parallel health success cancels pending requests", HealthProbeTests.SuccessCancelsPendingAsync);
await Test("Health requests share a bounded timeout", HealthProbeTests.SharedBudgetBoundsLatencyAsync);
await Test("Health caller cancellation propagates", HealthProbeTests.CallerCancellationPropagatesAsync);
await Test("Health rejects redirects, transport errors and HTTP 200", HealthProbeTests.FailuresDoNotProduceSuccessAsync);
await Test("Third health endpoint tolerates throttled peers", HealthProbeTests.ThirdEndpointRecoversThrottledPeersAsync);
await Test("Health opens fresh HTTPS sessions against a keep-alive server", HealthProbeTests.FreshConnectionsDoNotReuseSuccessfulSessionAsync);
await Test("TUN health requires both proxy and system DNS/HTTPS paths", HealthProbeTests.TunRequiresBothPathsAsync);
await Test("TUN paths share one bounded health deadline", HealthProbeTests.TunPathsShareBudgetAsync);
await Test("Proxy success cannot mask a timed-out system tunnel", HealthProbeTests.ProxySuccessDoesNotMaskSystemTimeoutAsync);
await Test("TUN health propagates caller cancellation", HealthProbeTests.TunCallerCancellationPropagatesAsync);
await Test("SOCKS diagnostics skip the system tunnel check", HealthProbeTests.SocksModeSkipsSystemPathAsync);
await Test("Recovery backoff bounded; one healthy probe does not clear attempts", () =>
{
var p = new RecoveryPolicy(); var now = DateTimeOffset.UtcNow;
foreach (var seconds in new[] { 2, 4, 8, 16, 32, 60, 60, 60, 60, 60, 60 })
{
p.Failed(now); Check(p.NextAttempt == now.AddSeconds(seconds));
Check(!p.CanAttempt(p.NextAttempt.AddTicks(-1))); Check(p.CanAttempt(p.NextAttempt));
}
Check(p.Attempts == 10); p.Healthy(); Check(p.Attempts == 10);
p.Stable(); Check(p.Attempts == 0); p.Reset(); Check(p.CanAttempt(now));
});
Console.WriteLine($"RESULT passed={passed} failed={failed}");
return failed == 0 ? 0 : 1;
}
private static async Task<int> CommandAsync(string[] args)
{
try
{
if (args.Length == 2 && args[0] == "--xray-check")
{
await XrayConfigTests.CheckEngineAsync(Path.GetFullPath(args[1])); return 0;
}
if (args.Length == 3 && args[0] == "--malformed-service" && args[1] == "--evidence-file")
return await MalformedServiceAsync(args[2]);
if (args.Length is 3 or 4 && args[0] == "--download" && args[1] == "--value-file" && (args.Length == 3 || args[3] == "--diagnose"))
return await DownloadCommandAsync((await File.ReadAllTextAsync(args[2])).Trim(), args.Length == 4);
if (args.Length == 2 && args[0] == "--live-parse")
{
var nodes = Subscription.Parse(await File.ReadAllTextAsync(args[1]));
// Counts only: never echo UUIDs, links, hosts or node names from a real subscription.
Console.WriteLine($"LIVE_PARSE nodes={nodes.Count}"); return 0;
}
if (args.Length == 2 && args[0] == "--engine-check")
{
var directory = NewTemporaryDirectory();
try
{
var node = Subscription.Parse(Link())[0];
foreach (var tun in new[] { false, true }) foreach (var russianDirect in new[] { false, true })
{
var path = Path.Combine(directory, "test-config.json");
await File.WriteAllTextAsync(path, ConfigBuilder.Build(node, tun, russianDirect, directory).ToJsonString());
var start = new ProcessStartInfo(Path.GetFullPath(args[1])) { UseShellExecute = false, CreateNoWindow = true, RedirectStandardOutput = true, RedirectStandardError = true };
start.ArgumentList.Add("check"); start.ArgumentList.Add("-c"); start.ArgumentList.Add(path);
using var process = Process.Start(start) ?? throw new InvalidOperationException();
var stdout = process.StandardOutput.ReadToEndAsync(); var stderr = process.StandardError.ReadToEndAsync();
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15));
try { await process.WaitForExitAsync(timeout.Token); }
catch { if (!process.HasExited) process.Kill(true); throw; }
await Task.WhenAll(stdout, stderr);
Console.WriteLine($"ENGINE_CHECK tun={tun} russianDirect={russianDirect} exit={process.ExitCode}");
if (process.ExitCode != 0) return 1;
}
return 0;
}
finally { RemoveTemporaryDirectory(directory); }
}
if (args.Length >= 2 && args[0] == "--client")
{
string? value = null; bool? enabled = null;
for (int i = 2; i < args.Length; i += 2)
{
if (i + 1 >= args.Length) throw new ArgumentException();
if (args[i] == "--value-file") value = (await File.ReadAllTextAsync(args[i + 1])).Trim();
else if (args[i] == "--value") value = args[i + 1];
else if (args[i] == "--enabled") enabled = bool.Parse(args[i + 1]);
else throw new ArgumentException();
}
var response = await PipeClient.SendAsync(new Request(args[1], value, enabled));
// Status exposes only public presentation fields. Requests/credentials are never echoed.
Console.WriteLine(JsonSerializer.Serialize(new { response.Ok, response.Error, response.Status }, Constants.Json));
return response.Ok ? 0 : 2;
}
Console.WriteLine("Usage: QVPN.Tests [--download --value-file private-file | --live-parse file | --engine-check executable | --client command [--value-file file] [--value public-node-id] [--enabled true/false]]");
return 2;
}
catch (Exception e) { Console.WriteLine("COMMAND_FAILED type=" + e.GetType().Name); return 1; }
}
private static async Task<int> DownloadCommandAsync(string url, bool diagnose)
{
var uri = Subscription.ValidateUrl(url);
var coreOk = false;
try
{
var result = await Subscription.DownloadAsync(url, default);
Console.WriteLine($"DOWNLOAD_CORE nodes={result.Nodes.Count}"); coreOk = true;
if (!diagnose) return 0;
}
catch (Exception e) { DiagnosticException("DOWNLOAD_CORE", e); }
try
{
var addresses = await Dns.GetHostAddressesAsync(uri.IdnHost);
Console.WriteLine($"DOWNLOAD_DNS ipv4={addresses.Count(a => a.AddressFamily == AddressFamily.InterNetwork)} ipv6={addresses.Count(a => a.AddressFamily == AddressFamily.InterNetworkV6)} systemProxyBypassed={HttpClient.DefaultProxy.IsBypassed(uri)}");
}
catch (Exception e) { DiagnosticException("DOWNLOAD_DNS", e); }
foreach (var mode in new[] { "direct", "system-proxy", "ipv4-direct" })
{
var errors = SslPolicyErrors.None;
var chainFlags = new HashSet<string>();
bool Validate(System.Security.Cryptography.X509Certificates.X509Certificate? certificate, System.Security.Cryptography.X509Certificates.X509Chain? chain, SslPolicyErrors policy)
{
errors = policy;
if (chain is not null) foreach (var status in chain.ChainStatus) chainFlags.Add(status.Status.ToString());
return policy == SslPolicyErrors.None; // Diagnostic callback keeps strict certificate validation.
}
using var handler = new SocketsHttpHandler
{
AllowAutoRedirect = false, UseProxy = mode == "system-proxy",
SslOptions = new System.Net.Security.SslClientAuthenticationOptions { RemoteCertificateValidationCallback = (_, c, ch, err) => Validate(c, ch, err) }
};
if (mode == "ipv4-direct") handler.ConnectCallback = async (context, token) =>
{
var addresses = await Dns.GetHostAddressesAsync(context.DnsEndPoint.Host, AddressFamily.InterNetwork, token);
foreach (var address in addresses)
{
var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
try { await socket.ConnectAsync(address, context.DnsEndPoint.Port, token); return new NetworkStream(socket, true); }
catch { socket.Dispose(); }
}
throw new SocketException((int)SocketError.HostUnreachable);
};
using var http = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(12) };
http.DefaultRequestHeaders.UserAgent.ParseAdd("v2rayN/7.0 QVPN/1.0");
try
{
using var response = await http.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead);
Console.WriteLine($"DOWNLOAD_TRANSPORT mode={mode} http={(int)response.StatusCode} tls={errors} chain={string.Join(',', chainFlags)}");
}
catch (Exception e)
{
DiagnosticException("DOWNLOAD_TRANSPORT mode=" + mode, e);
Console.WriteLine($"DOWNLOAD_TLS mode={mode} errors={errors} chain={string.Join(',', chainFlags)}");
}
}
return coreOk ? 0 : 1;
}
private static async Task<int> MalformedServiceAsync(string evidencePath)
{
// Authorized diagnostic regression: invalid frames only, no control-state commands.
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(40));
var before = await PipeClient.SendAsync(new Request("status"), timeout.Token);
var samples = new List<object>();
var payload = Encoding.UTF8.GetBytes("{not-json}");
var frame = new byte[payload.Length + 4]; BinaryPrimitives.WriteInt32LittleEndian(frame, payload.Length); payload.CopyTo(frame, 4);
for (var i = 0; i < 8; i++)
{
using (var pipe = new NamedPipeClientStream(".", Constants.PipeName, PipeDirection.InOut, PipeOptions.Asynchronous, TokenImpersonationLevel.Identification))
{
await pipe.ConnectAsync(2500, timeout.Token);
await pipe.WriteAsync(frame, timeout.Token); await pipe.FlushAsync(timeout.Token);
var one = new byte[1]; var read = await pipe.ReadAsync(one, timeout.Token);
Check(read == 0); // Malformed input is closed without logging or executing a command.
}
var status = await PipeClient.SendAsync(new Request("status"), timeout.Token);
Check(status.Ok && status.Status is not null);
samples.Add(new { Iteration = i + 1, StatusReachable = status.Ok, State = status.Status!.State, status.Status.EnginePid });
Console.WriteLine($"MALFORMED_SERVICE iteration={i + 1} status={status.Ok} state={status.Status.State}");
}
var after = await PipeClient.SendAsync(new Request("status"), timeout.Token);
Check(after.Ok && before.Status is not null && after.Status is not null);
var evidence = new
{
CapturedUtc = DateTimeOffset.UtcNow, Mode = "existing diagnostic console, SOCKS only; no TUN/stop/restart commands",
MalformedFrames = 8, IdentificationClient = true, StatusSurvived = true,
Before = new { before.Status!.State, before.Status.EnginePid, before.Status.DesiredConnected, Nodes = before.Status.Nodes.Count },
After = new { after.Status!.State, after.Status.EnginePid, after.Status.DesiredConnected, Nodes = after.Status.Nodes.Count },
EngineUnchanged = before.Status.EnginePid == after.Status.EnginePid,
Samples = samples
};
await File.WriteAllTextAsync(Path.GetFullPath(evidencePath), JsonSerializer.Serialize(evidence, new JsonSerializerOptions { WriteIndented = true }));
Console.WriteLine("MALFORMED_SERVICE passed=8 status-survived=True engine-unchanged=" + evidence.EngineUnchanged);
return 0;
}
private static void DiagnosticException(string label, Exception exception)
{
for (Exception? e = exception; e is not null; e = e.InnerException)
{
// Fixed enums and numeric codes only; Message/ToString can contain the private URL.
var code = e is HttpRequestException http ? " httpError=" + http.HttpRequestError + " status=" + http.StatusCode : e is SocketException socket ? " socketError=" + socket.SocketErrorCode : "";
Console.WriteLine($"{label} type={e.GetType().Name} hresult=0x{e.HResult:X8}{code}");
}
}
private static string Link(string host = "8.8.8.8", int port = 443, string name = "Test location") =>
$"vless://{FakeUuid}@{host}:{port}?security=reality&type=tcp&headerType=none&encryption=none&pbk={FakePublicKey}&sid=abcd&sni=example.com&fp=chrome&flow=xtls-rprx-vision#{name}";
private static Task StoreAsync()
{
var directory = NewTemporaryDirectory();
var stage = "acl";
try
{
var owner = WindowsIdentity.GetCurrent().User ?? throw new InvalidOperationException();
var allowed = new[] { owner, new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null), new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null) };
var acl = new DirectoryInfo(directory).GetAccessControl(); acl.SetAccessRuleProtection(true, false);
foreach (FileSystemAccessRule inheritedRule in acl.GetAccessRules(true, false, typeof(SecurityIdentifier))) acl.RemoveAccessRuleSpecific(inheritedRule);
foreach (var sid in allowed) acl.AddAccessRule(new FileSystemAccessRule(sid, FileSystemRights.FullControl, InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, PropagationFlags.None, AccessControlType.Allow));
new DirectoryInfo(directory).SetAccessControl(acl);
var store = new SecureStore(directory);
Check(store.Load().Nodes.Count == 0);
var settings = new Settings { SubscriptionUrl = FakeUrl, Nodes = Subscription.Parse(Link()), DesiredConnected = true, RussianDirect = false, SelectedId = "sentinel-test-id" };
stage = "first-save";
store.Save(settings);
stage = "first-load";
var read = store.Load(); Check(read.SubscriptionUrl == FakeUrl && read.Nodes.Count == 1 && read.DesiredConnected && !read.RussianDirect);
stage = "replace-save";
settings.AutoFailover = false; store.Save(settings); Check(!store.Load().AutoFailover);
stage = "encrypted-acl-check";
Check(File.Exists(Path.Combine(directory, "settings.dpapi.backup")) && !File.Exists(Path.Combine(directory, "settings.dpapi.new")));
foreach (var file in Directory.GetFiles(directory))
{
var bytes = File.ReadAllBytes(file);
Check(!Encoding.UTF8.GetString(bytes).Contains(FakeUrl) && !Encoding.UTF8.GetString(bytes).Contains(FakeUuid));
foreach (FileSystemAccessRule rule in new FileInfo(file).GetAccessControl().GetAccessRules(true, true, typeof(SecurityIdentifier)))
if (rule.AccessControlType == AccessControlType.Allow) Check(allowed.Any(sid => sid == (SecurityIdentifier)rule.IdentityReference));
}
File.WriteAllBytes(Path.Combine(directory, "settings.dpapi"), new byte[32]);
stage = "corruption-check";
Throws<CryptographicException>(() => store.Load());
}
catch { Console.WriteLine("STORE_FAILED stage=" + stage); throw; }
finally { RemoveTemporaryDirectory(directory); }
return Task.CompletedTask;
}
private static void Config()
{
var node = Subscription.Parse(Link())[0];
var config = ConfigBuilder.Build(node, true, true, Path.GetTempPath(), 18999);
var tun = config["inbounds"]!.AsArray().Single(n => n?["type"]?.GetValue<string>() == "tun")!;
Check(tun["auto_route"]!.GetValue<bool>() && tun["strict_route"]!.GetValue<bool>() && tun["dns_mode"]!.GetValue<string>() == "hijack");
Check(tun["address"]!.AsArray().Count == 2);
var health = config["inbounds"]!.AsArray().Single(n => n?["tag"]?.GetValue<string>() == "health-in")!;
Check(health["listen"]!.GetValue<string>() == "127.0.0.1" && health["listen_port"]!.GetValue<int>() == 18999);
var dns = config["dns"]!; Check(dns["final"]!.GetValue<string>() == "remote-dns");
var server = dns["servers"]!.AsArray().Single()!;
Check(server["type"]!.GetValue<string>() == "https" && server["detour"]!.GetValue<string>() == "proxy" && server["tls"]!["enabled"]!.GetValue<bool>());
var route = config["route"]!; Check(route["final"]!.GetValue<string>() == "proxy" && route["auto_detect_interface"]!.GetValue<bool>());
var rules = route["rules"]!.AsArray();
var healthRule = rules.Single(r => r?["inbound"]?.GetValue<string>() == "health-in")!;
Check(healthRule["outbound"]!.GetValue<string>() == "proxy");
Check(rules.IndexOf(healthRule) < rules.Select((r, i) => (r, i)).Where(x => x.r?["outbound"]?.GetValue<string>() == "direct").Min(x => x.i));
Check(rules.Any(r => r?["action"]?.GetValue<string>() == "hijack-dns"));
var proxy = config["outbounds"]!.AsArray().Single(n => n?["tag"]?.GetValue<string>() == "proxy")!;
Check(proxy["type"]!.GetValue<string>() == "vless" && proxy["tls"]!["reality"]!["enabled"]!.GetValue<bool>());
Check(config["log"]!["level"]!.GetValue<string>() == "error");
}
private static string NewTemporaryDirectory()
{
var path = Path.Combine(Path.GetTempPath(), "QVPN.Tests-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(path); return path;
}
private static void RemoveTemporaryDirectory(string path)
{
var absolute = Path.GetFullPath(path); var root = Path.GetFullPath(Path.GetTempPath()).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar;
if (!absolute.StartsWith(root, StringComparison.OrdinalIgnoreCase) || !Path.GetFileName(absolute).StartsWith("QVPN.Tests-", StringComparison.Ordinal)) throw new InvalidOperationException("Unsafe test cleanup target.");
Directory.Delete(absolute, true);
}
private static Task Test(string name, Action action) => Test(name, () => { action(); return Task.CompletedTask; });
private static async Task Test(string name, Func<Task> action)
{
try { await action(); passed++; Console.WriteLine("PASS " + name); }
catch (Exception e) { failed++; Console.WriteLine("FAIL " + name + " type=" + e.GetType().Name); }
}
private static void Check(bool condition) { if (!condition) throw new TestFailure(); }
private static void Throws<T>(Action action) where T : Exception
{
try { action(); } catch (T) { return; } throw new TestFailure();
}
private static async Task ThrowsAsync<T>(Func<Task> action) where T : Exception
{
try { await action(); } catch (T) { return; } throw new TestFailure();
}
private sealed class TestFailure : Exception;
}