实现 .NET 10 托盘版网关保活
包含分层探测、可补偿的 IPv4 路由/DNS 切换、自动策略、托盘与分组设置界面、只读诊断工具、离线测试和发布脚本。
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
using GatewayKeepalive.Core;
|
||||
|
||||
namespace GatewayKeepalive.Tests;
|
||||
|
||||
internal sealed class CheckFailedException(string message, Exception? inner = null) : Exception(message, inner);
|
||||
|
||||
internal static class Check
|
||||
{
|
||||
public static void True(bool condition, string message)
|
||||
{
|
||||
if (!condition) throw new CheckFailedException(message);
|
||||
}
|
||||
|
||||
public static void False(bool condition, string message) => True(!condition, message);
|
||||
|
||||
public static void Equal<T>(T expected, T actual, string message)
|
||||
{
|
||||
if (!EqualityComparer<T>.Default.Equals(expected, actual))
|
||||
throw new CheckFailedException($"{message} (expected: {expected}, actual: {actual})");
|
||||
}
|
||||
|
||||
public static void Contains(string expected, string actual, string message)
|
||||
{
|
||||
if (!actual.Contains(expected, StringComparison.Ordinal))
|
||||
throw new CheckFailedException($"{message} (missing: {expected}, actual: {actual})");
|
||||
}
|
||||
|
||||
public static void SequenceEqual<T>(IEnumerable<T> expected, IEnumerable<T> actual, string message)
|
||||
{
|
||||
var left = expected.ToArray();
|
||||
var right = actual.ToArray();
|
||||
if (!left.SequenceEqual(right))
|
||||
throw new CheckFailedException($"{message} (expected: [{string.Join(", ", left)}], actual: [{string.Join(", ", right)}])");
|
||||
}
|
||||
|
||||
public static TException Throws<TException>(Action action, string message) where TException : Exception
|
||||
{
|
||||
try { action(); }
|
||||
catch (TException exception) { return exception; }
|
||||
catch (Exception exception) { throw new CheckFailedException($"{message}: expected {typeof(TException).Name}, got {exception.GetType().Name}: {exception.Message}", exception); }
|
||||
throw new CheckFailedException($"{message}: expected {typeof(TException).Name}, but no exception was thrown");
|
||||
}
|
||||
|
||||
public static async Task<TException> ThrowsAsync<TException>(Func<Task> action, string message) where TException : Exception
|
||||
{
|
||||
try { await action(); }
|
||||
catch (TException exception) { return exception; }
|
||||
catch (Exception exception) { throw new CheckFailedException($"{message}: expected {typeof(TException).Name}, got {exception.GetType().Name}: {exception.Message}", exception); }
|
||||
throw new CheckFailedException($"{message}: expected {typeof(TException).Name}, but no exception was thrown");
|
||||
}
|
||||
|
||||
public static async Task<Exception> ThrowsAnyAsync(Func<Task> action, string message)
|
||||
{
|
||||
try { await action(); }
|
||||
catch (Exception exception) { return exception; }
|
||||
throw new CheckFailedException($"{message}: expected an exception, but no exception was thrown");
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class ManualTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _utcNow = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
private long _timestamp;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _utcNow;
|
||||
public override long GetTimestamp() => _timestamp;
|
||||
public override long TimestampFrequency => TimeSpan.TicksPerSecond;
|
||||
|
||||
public void Advance(TimeSpan amount)
|
||||
{
|
||||
if (amount < TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(amount));
|
||||
_utcNow += amount;
|
||||
_timestamp += amount.Ticks;
|
||||
}
|
||||
|
||||
public void AdvanceSeconds(int seconds) => Advance(TimeSpan.FromSeconds(seconds));
|
||||
}
|
||||
|
||||
internal sealed class TempDirectory : IDisposable
|
||||
{
|
||||
public TempDirectory()
|
||||
{
|
||||
// The test host may not be allowed to create siblings in the system temp root.
|
||||
// Keep disposable state beside the test binary; it is still outside the product data paths.
|
||||
DirectoryPath = Path.Combine(AppContext.BaseDirectory, "test-temp", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(DirectoryPath);
|
||||
}
|
||||
|
||||
public string DirectoryPath { get; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(DirectoryPath)) Directory.Delete(DirectoryPath, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record BackendWrite(string Operation, string Target);
|
||||
|
||||
internal sealed class FakeBackend(NetworkSnapshot initial) : INetworkBackend
|
||||
{
|
||||
public bool IsAdministrator { get; set; } = true;
|
||||
public NetworkSnapshot Current { get; set; } = initial;
|
||||
public List<BackendWrite> Writes { get; } = [];
|
||||
public int CaptureCount { get; private set; }
|
||||
public Func<Guid, CancellationToken, Task<NetworkSnapshot>>? CaptureBehavior { get; set; }
|
||||
public Func<NetworkSnapshot, Task>? RouteWriteBehavior { get; set; }
|
||||
public Func<DnsConfiguration, Task>? DnsWriteBehavior { get; set; }
|
||||
|
||||
public Task<IReadOnlyList<AdapterInfo>> ListAdaptersAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return Task.FromResult<IReadOnlyList<AdapterInfo>>([Current.Adapter]);
|
||||
}
|
||||
|
||||
public async Task<NetworkSnapshot> CaptureAsync(Guid adapterId, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
CaptureCount++;
|
||||
Check.Equal(Current.Adapter.Id, adapterId, "The service captured an unexpected adapter");
|
||||
return CaptureBehavior is null
|
||||
? Current
|
||||
: await CaptureBehavior(adapterId, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task WriteRoutesAsync(NetworkSnapshot desired, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
Writes.Add(new("route", desired.Gateway ?? "<empty>"));
|
||||
if (RouteWriteBehavior is not null) await RouteWriteBehavior(desired);
|
||||
else ApplyRoutes(desired);
|
||||
}
|
||||
|
||||
public async Task WriteDnsAsync(Guid adapterId, DnsConfiguration desired, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
Check.Equal(Current.Adapter.Id, adapterId, "The service wrote DNS for an unexpected adapter");
|
||||
var target = desired.Automatic ? "auto" : string.Join(",", desired.Servers);
|
||||
Writes.Add(new("dns", target));
|
||||
if (DnsWriteBehavior is not null) await DnsWriteBehavior(desired);
|
||||
else ApplyDns(desired);
|
||||
}
|
||||
|
||||
public void ApplyRoutes(NetworkSnapshot desired) => Current = Current with { Routes = desired.Routes };
|
||||
|
||||
public void ApplyDns(DnsConfiguration desired) => Current = Current with
|
||||
{
|
||||
Dns = desired,
|
||||
EffectiveDns = desired.Automatic ? [] : desired.Servers,
|
||||
};
|
||||
}
|
||||
|
||||
internal sealed class FakeProbe : IHealthProbe
|
||||
{
|
||||
public HealthReport Report { get; set; } = new([], ProxyHealthy: true, InternetHealthy: true, DateTimeOffset.UtcNow);
|
||||
public bool VerifyResult { get; set; } = true;
|
||||
public Exception? VerifyException { get; set; }
|
||||
public Func<AppSettings, NetworkSnapshot, CancellationToken, Task<HealthReport>>? CheckBehavior { get; set; }
|
||||
public int CheckCount { get; private set; }
|
||||
public int VerifyCount { get; private set; }
|
||||
public NetworkSnapshot? VerifiedSnapshot { get; private set; }
|
||||
public CancellationToken VerifyToken { get; private set; }
|
||||
|
||||
public async Task<HealthReport> CheckAsync(AppSettings settings, NetworkSnapshot snapshot, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
CheckCount++;
|
||||
return CheckBehavior is null
|
||||
? Report with { CheckedAt = DateTimeOffset.UtcNow }
|
||||
: await CheckBehavior(settings, snapshot, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<bool> VerifyInternetAsync(AppSettings settings, NetworkSnapshot snapshot, CancellationToken cancellationToken)
|
||||
{
|
||||
VerifyCount++;
|
||||
VerifiedSnapshot = snapshot;
|
||||
VerifyToken = cancellationToken;
|
||||
if (VerifyException is not null) throw VerifyException;
|
||||
return Task.FromResult(VerifyResult);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class SwitchHarness : IDisposable
|
||||
{
|
||||
public SwitchHarness(NetworkSnapshot initial, bool administrator = true)
|
||||
{
|
||||
Backend = new FakeBackend(initial) { IsAdministrator = administrator };
|
||||
Probe = new FakeProbe();
|
||||
Store = new JsonStore(Temp.DirectoryPath);
|
||||
Service = new SwitchService(Backend, Probe, Store, Logs.Add);
|
||||
}
|
||||
|
||||
public TempDirectory Temp { get; } = new();
|
||||
public FakeBackend Backend { get; }
|
||||
public FakeProbe Probe { get; }
|
||||
public JsonStore Store { get; }
|
||||
public SwitchService Service { get; }
|
||||
public List<string> Logs { get; } = [];
|
||||
|
||||
public void Dispose() => Temp.Dispose();
|
||||
}
|
||||
|
||||
internal static class Fixtures
|
||||
{
|
||||
public static readonly Guid AdapterId = Guid.Parse("2C64EAB9-27A7-45CC-8C26-9DC77C54A58B");
|
||||
|
||||
public static AppSettings Settings() => new()
|
||||
{
|
||||
Configured = true,
|
||||
AdapterId = AdapterId,
|
||||
DirectGateway = "192.168.6.1",
|
||||
ProxyGateway = "192.168.6.2",
|
||||
ProxyDns = "192.168.6.2",
|
||||
DirectDnsAutomatic = true,
|
||||
UseSocks = true,
|
||||
SocksPort = 1070,
|
||||
TestHosts = ["probe.invalid"],
|
||||
RequiredHealthyTargets = 1,
|
||||
IntervalSeconds = 5,
|
||||
SuccessThreshold = 3,
|
||||
FailureThreshold = 3,
|
||||
RecoveryCooldownSeconds = 30,
|
||||
FailureCooldownSeconds = 5,
|
||||
ProbeTimeoutMs = 500,
|
||||
RoundTimeoutMs = 1000,
|
||||
Policy = ControlPolicy.Auto,
|
||||
};
|
||||
|
||||
public static NetworkSnapshot Baseline(string fingerprint = "network-a")
|
||||
{
|
||||
var adapter = new AdapterInfo(
|
||||
AdapterId,
|
||||
7,
|
||||
"Offline test adapter",
|
||||
Up: true,
|
||||
Addresses: [new Ipv4Address("192.168.6.10", 24)],
|
||||
InterfaceMetric: 25,
|
||||
NetworkFingerprint: fingerprint);
|
||||
var route = new RouteEntry("192.168.6.1", 10, 0x1000, 7);
|
||||
return new NetworkSnapshot(adapter, [route], DnsConfiguration.Auto, ["192.168.6.1"], DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
public static HealthReport Health(bool proxyHealthy, bool internetHealthy) =>
|
||||
new([], proxyHealthy, internetHealthy, DateTimeOffset.UtcNow);
|
||||
}
|
||||
Reference in New Issue
Block a user