Implement QVPN Windows client for BlancVPN with resilient VPN service
This commit is contained in:
@@ -0,0 +1,415 @@
|
||||
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("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 == 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;
|
||||
}
|
||||
Reference in New Issue
Block a user