实现 .NET 10 托盘版网关保活
包含分层探测、可补偿的 IPv4 路由/DNS 切换、自动策略、托盘与分组设置界面、只读诊断工具、离线测试和发布脚本。
This commit is contained in:
@@ -0,0 +1,354 @@
|
||||
using GatewayKeepalive.Core;
|
||||
|
||||
namespace GatewayKeepalive.Tests;
|
||||
|
||||
internal static class GatewayControllerTests
|
||||
{
|
||||
public static async Task InitialUnconfiguredDoesNotProbeOrWriteAsync()
|
||||
{
|
||||
using var harness = new ControllerHarness(Fixtures.Baseline());
|
||||
|
||||
await harness.Controller.RefreshAsync();
|
||||
|
||||
Check.False(harness.Controller.Settings.Configured, "A fresh controller should remain unconfigured");
|
||||
Check.Equal(NetworkMode.Initializing, harness.Controller.Status.Mode, "Unconfigured refresh should publish the initial state");
|
||||
Check.Equal(0, harness.Backend.CaptureCount, "Unconfigured refresh must not capture the adapter");
|
||||
Check.Equal(0, harness.Probe.CheckCount, "Unconfigured refresh must not probe");
|
||||
Check.Equal(0, harness.Backend.Writes.Count, "Unconfigured refresh must not write");
|
||||
Check.Equal(0, Directory.GetFiles(harness.Temp.DirectoryPath).Length, "Unconfigured refresh must not create state files");
|
||||
}
|
||||
|
||||
public static async Task ExplicitPausedSavePreservesRealDirectDnsAsync()
|
||||
{
|
||||
var directDns = new DnsConfiguration(Automatic: false, Servers: ["192.168.6.53"], ProfileServers: []);
|
||||
var baseline = Fixtures.Baseline() with { Dns = directDns, EffectiveDns = ["192.168.6.53"] };
|
||||
using var harness = new ControllerHarness(baseline);
|
||||
var settings = Fixtures.Settings() with
|
||||
{
|
||||
Policy = new AppSettings().Policy,
|
||||
PreserveOriginalDirectDns = true,
|
||||
DirectDnsAutomatic = true,
|
||||
};
|
||||
Check.Equal(ControlPolicy.Paused, settings.Policy, "The product default policy should be paused");
|
||||
|
||||
await harness.Controller.SaveSettingsAsync(settings);
|
||||
|
||||
var session = harness.Store.Read<SessionState>("session.json");
|
||||
Check.True(session is not null, "Save should persist the adoption session");
|
||||
Check.False(string.IsNullOrWhiteSpace(session!.SettingsFingerprint), "Save should persist a settings fingerprint");
|
||||
Check.False(session.DirectDns is null, "Save should preserve the real direct DNS configuration");
|
||||
Check.False(session.DirectDns!.Automatic, "Static direct DNS must not be replaced by automatic DNS");
|
||||
Check.SequenceEqual(["192.168.6.53"], session.DirectDns.Servers, "The adapter's static direct DNS should be retained");
|
||||
Check.True(NetworkComparison.SameConfiguration(baseline, session.Baseline), "Baseline should be the real pre-adoption snapshot");
|
||||
Check.True(NetworkComparison.SameConfiguration(baseline, session.Expected), "Expected should equal the current direct snapshot");
|
||||
Check.Equal(ControlPolicy.Paused, harness.Store.Read<AppSettings>("settings.json")?.Policy, "An explicit first save should remain paused");
|
||||
Check.Equal(0, harness.Backend.Writes.Count, "A paused save must not change routes or DNS");
|
||||
Check.Equal(0, harness.Probe.CheckCount, "Save must not probe");
|
||||
Check.True(NetworkComparison.SameConfiguration(baseline, harness.Backend.Current), "The live configuration must remain untouched");
|
||||
}
|
||||
|
||||
public static async Task ExternalConfigurationChangeIsNotOverwrittenAsync()
|
||||
{
|
||||
var baseline = Fixtures.Baseline();
|
||||
using var harness = new ControllerHarness(baseline);
|
||||
var settings = Fixtures.Settings();
|
||||
await harness.Controller.SaveSettingsAsync(settings);
|
||||
var externalRoute = baseline.Routes[0] with { Gateway = "192.168.6.254", Metric = 7 };
|
||||
harness.Backend.Current = baseline with { Routes = [externalRoute] };
|
||||
|
||||
await harness.Controller.RefreshAsync();
|
||||
|
||||
Check.Equal(NetworkMode.ExternalConfiguration, harness.Controller.Status.Mode, "A changed route should require explicit adoption");
|
||||
Check.Equal(ControlPolicy.Paused, harness.Controller.Status.Policy, "External configuration should pause automatic control");
|
||||
Check.Equal(0, harness.Probe.CheckCount, "External configuration must be detected before probing");
|
||||
Check.Equal(0, harness.Backend.Writes.Count, "External configuration must never be overwritten automatically");
|
||||
Check.Equal("192.168.6.254", harness.Backend.Current.Gateway, "The external route should remain in place");
|
||||
Check.Equal(ControlPolicy.Auto, harness.Store.Read<AppSettings>("settings.json")?.Policy, "Refresh must not rewrite the saved policy");
|
||||
}
|
||||
|
||||
public static async Task LegacySessionWithoutFingerprintIsNotWritableAsync()
|
||||
{
|
||||
var baseline = Fixtures.Baseline();
|
||||
using var harness = new ControllerHarness(baseline, Fixtures.Settings());
|
||||
harness.Store.Write("session.json", new SessionState(baseline, baseline, DnsConfiguration.Auto));
|
||||
|
||||
await harness.Controller.RefreshAsync();
|
||||
|
||||
Check.Equal(NetworkMode.ExternalConfiguration, harness.Controller.Status.Mode, "A session with the default empty fingerprint must not claim the new settings");
|
||||
Check.Equal(ControlPolicy.Paused, harness.Controller.Status.Policy, "Legacy session refusal should pause automatic control");
|
||||
Check.Equal(0, harness.Probe.CheckCount, "Fingerprint mismatch must be rejected before probing");
|
||||
Check.Equal(0, harness.Backend.Writes.Count, "Fingerprint mismatch must not write");
|
||||
}
|
||||
|
||||
public static async Task SaveFailureAfterSessionWriteDoesNotBecomeWritableAsync()
|
||||
{
|
||||
var baseline = Fixtures.Baseline();
|
||||
var oldSettings = Fixtures.Settings() with { Policy = ControlPolicy.Paused, ProxyDns = "192.168.6.2" };
|
||||
using var harness = new ControllerHarness(baseline, oldSettings);
|
||||
var settingsPath = harness.FilePath("settings.json");
|
||||
File.Delete(settingsPath);
|
||||
Directory.CreateDirectory(settingsPath);
|
||||
var uncommitted = oldSettings with { ProxyDns = "192.168.6.3" };
|
||||
|
||||
await Check.ThrowsAsync<IOException>(
|
||||
() => harness.Controller.SaveSettingsAsync(uncommitted),
|
||||
"A settings write failure should fail the save");
|
||||
|
||||
Check.True(harness.Store.Read<SessionState>("session.json") is not null, "The test should fail after session.json was written");
|
||||
Check.Equal("192.168.6.2", harness.Controller.Settings.ProxyDns, "Failed save must not publish uncommitted settings in memory");
|
||||
|
||||
await harness.Controller.RefreshAsync();
|
||||
|
||||
Check.Equal(NetworkMode.ExternalConfiguration, harness.Controller.Status.Mode, "A partial session/settings write must not become writable");
|
||||
Check.Equal(ControlPolicy.Paused, harness.Controller.Status.Policy, "A partial write should pause automatic control");
|
||||
Check.Equal(0, harness.Probe.CheckCount, "A partial write must not reach health probing");
|
||||
Check.Equal(0, harness.Backend.Writes.Count, "A partial write must not alter routes or DNS");
|
||||
Check.Equal("192.168.6.2", harness.Store.Read<AppSettings>("settings.json")?.ProxyDns ?? harness.Controller.Settings.ProxyDns, "No new settings should be committed");
|
||||
}
|
||||
|
||||
public static async Task UnfinishedJournalBlocksAutomaticControlAsync()
|
||||
{
|
||||
var baseline = Fixtures.Baseline();
|
||||
using var harness = new ControllerHarness(baseline);
|
||||
await harness.Controller.SaveSettingsAsync(Fixtures.Settings());
|
||||
var intended = baseline with
|
||||
{
|
||||
Routes =
|
||||
[
|
||||
baseline.Routes[0] with { Metric = 101 },
|
||||
new RouteEntry("192.168.6.2", 1, baseline.Routes[0].InterfaceLuid, baseline.Routes[0].InterfaceIndex),
|
||||
],
|
||||
Dns = new DnsConfiguration(false, ["192.168.6.2"], []),
|
||||
};
|
||||
var journal = new SwitchJournal(baseline, intended, "ApplyingDns", DateTimeOffset.UtcNow);
|
||||
harness.Store.Write(SwitchService.JournalFile, journal);
|
||||
|
||||
await harness.Controller.RefreshAsync();
|
||||
|
||||
Check.Equal(NetworkMode.RecoveryRequired, harness.Controller.Status.Mode, "An unfinished journal must require recovery");
|
||||
Check.Equal(ControlPolicy.Paused, harness.Controller.Status.Policy, "Automatic control must pause for an unfinished journal");
|
||||
Check.Equal(0, harness.Probe.CheckCount, "A journal must block checks before probing");
|
||||
Check.Equal(0, harness.Backend.Writes.Count, "A journal must block all automatic writes");
|
||||
Check.True(harness.Store.Read<SwitchJournal>(SwitchService.JournalFile) is not null, "Automatic refresh must retain the recovery journal");
|
||||
Check.Equal(ControlPolicy.Auto, harness.Store.Read<AppSettings>("settings.json")?.Policy, "The saved Auto policy should remain available for after recovery");
|
||||
}
|
||||
|
||||
public static async Task FixedFailureKeepsFixedPolicyAfterRollbackAsync()
|
||||
{
|
||||
var baseline = Fixtures.Baseline();
|
||||
using var harness = new ControllerHarness(baseline);
|
||||
var settings = Fixtures.Settings() with { SuccessThreshold = 1 };
|
||||
await harness.Controller.SaveSettingsAsync(settings);
|
||||
harness.Probe.VerifyResult = false;
|
||||
|
||||
await Check.ThrowsAsync<IOException>(
|
||||
() => harness.Controller.SetPolicyAsync(ControlPolicy.ForceProxy),
|
||||
"Failed fixed-mode egress verification should fail the policy change");
|
||||
|
||||
Check.Equal(ControlPolicy.ForceProxy, harness.Controller.Settings.Policy, "A successful rollback must retain the fixed policy");
|
||||
Check.Equal(ControlPolicy.ForceProxy, harness.Store.Read<AppSettings>("settings.json")?.Policy, "The persisted policy must remain fixed");
|
||||
Check.True(NetworkComparison.SameConfiguration(baseline, harness.Backend.Current), "Rollback should restore the direct configuration");
|
||||
Check.Equal<SwitchJournal?>(null, harness.Store.Read<SwitchJournal>(SwitchService.JournalFile), "Successful rollback should clear the journal");
|
||||
Check.Equal(4, harness.Backend.Writes.Count, "Fixed switch should write and then fully roll back");
|
||||
|
||||
harness.Probe.Report = Fixtures.Health(proxyHealthy: true, internetHealthy: true);
|
||||
await harness.Controller.RefreshAsync();
|
||||
|
||||
Check.Equal(ControlPolicy.ForceProxy, harness.Controller.Status.Policy, "Health evaluation must not replace the fixed policy with Auto");
|
||||
Check.Equal(ControlPolicy.ForceProxy, harness.Controller.Settings.Policy, "Refresh must keep the fixed policy in memory");
|
||||
Check.Equal(NetworkMode.Direct, harness.Controller.Status.Mode, "The rollback should leave the controller in direct mode");
|
||||
Check.Equal(4, harness.Backend.Writes.Count, "A fixed policy failure must not trigger an automatic retry");
|
||||
Check.Equal(1, harness.Probe.CheckCount, "Refresh should observe health without switching");
|
||||
}
|
||||
|
||||
public static async Task ConcurrentRefreshIsCoalescedAsync()
|
||||
{
|
||||
using var harness = new ControllerHarness(Fixtures.Baseline());
|
||||
await harness.Controller.SaveSettingsAsync(Fixtures.Settings());
|
||||
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
harness.Probe.CheckBehavior = async (_, _, cancellationToken) =>
|
||||
{
|
||||
entered.TrySetResult();
|
||||
await release.Task.WaitAsync(cancellationToken);
|
||||
return Fixtures.Health(proxyHealthy: true, internetHealthy: true);
|
||||
};
|
||||
|
||||
var first = harness.Controller.RefreshAsync();
|
||||
try
|
||||
{
|
||||
await entered.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
var second = harness.Controller.RefreshAsync();
|
||||
await second.WaitAsync(TimeSpan.FromSeconds(1));
|
||||
Check.Equal(1, harness.Probe.CheckCount, "A concurrent refresh should be merged into the running check");
|
||||
}
|
||||
finally { release.TrySetResult(); }
|
||||
await first.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
Check.Equal(0, harness.Backend.Writes.Count, "A below-threshold check must not write");
|
||||
|
||||
await harness.Controller.RefreshAsync();
|
||||
Check.Equal(2, harness.Probe.CheckCount, "The merge flag must reset after the running check completes");
|
||||
}
|
||||
|
||||
public static async Task SaveAndRefreshAreSerializedAsync()
|
||||
{
|
||||
using var harness = new ControllerHarness(Fixtures.Baseline());
|
||||
var captureEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var releaseCapture = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var probeEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var captures = 0;
|
||||
harness.Backend.CaptureBehavior = async (_, cancellationToken) =>
|
||||
{
|
||||
if (Interlocked.Increment(ref captures) == 1)
|
||||
{
|
||||
captureEntered.TrySetResult();
|
||||
await releaseCapture.Task.WaitAsync(cancellationToken);
|
||||
}
|
||||
return harness.Backend.Current;
|
||||
};
|
||||
harness.Probe.CheckBehavior = (_, _, _) =>
|
||||
{
|
||||
probeEntered.TrySetResult();
|
||||
return Task.FromResult(Fixtures.Health(proxyHealthy: true, internetHealthy: true));
|
||||
};
|
||||
var settings = Fixtures.Settings() with { Policy = ControlPolicy.Paused };
|
||||
|
||||
var save = harness.Controller.SaveSettingsAsync(settings);
|
||||
Task? refresh = null;
|
||||
var probeStartedWhileSaveHeldGate = false;
|
||||
var refreshCompletedWhileSaveHeldGate = false;
|
||||
try
|
||||
{
|
||||
await captureEntered.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
refresh = harness.Controller.RefreshAsync();
|
||||
var earlyWinner = await Task.WhenAny(probeEntered.Task, Task.Delay(TimeSpan.FromMilliseconds(150)));
|
||||
probeStartedWhileSaveHeldGate = ReferenceEquals(earlyWinner, probeEntered.Task);
|
||||
refreshCompletedWhileSaveHeldGate = refresh.IsCompleted;
|
||||
}
|
||||
finally { releaseCapture.TrySetResult(); }
|
||||
await save.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
if (refresh is not null) await refresh.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
|
||||
Check.False(probeStartedWhileSaveHeldGate, "Refresh must wait for the in-progress save");
|
||||
Check.False(refreshCompletedWhileSaveHeldGate, "Refresh must not complete while another write operation holds the gate");
|
||||
Check.Equal(1, harness.Probe.CheckCount, "Refresh should run once after the save releases the gate");
|
||||
Check.Equal(2, harness.Backend.CaptureCount, "Save and the subsequent refresh should capture sequentially");
|
||||
Check.Equal(0, harness.Backend.Writes.Count, "Neither operation should write in paused mode");
|
||||
}
|
||||
|
||||
public static async Task StopRestoresWhenRequestedAsync()
|
||||
{
|
||||
var baseline = Fixtures.Baseline();
|
||||
using var harness = new ControllerHarness(baseline);
|
||||
var settings = Fixtures.Settings();
|
||||
await SwitchToProxyAsync(harness, settings);
|
||||
await harness.Controller.SaveSettingsAsync(settings with { Policy = ControlPolicy.Paused, RestoreOnExit = true });
|
||||
Check.Equal(2, harness.Backend.Writes.Count, "Proxy setup should be the only network change before stop");
|
||||
|
||||
await harness.Controller.StopAsync();
|
||||
|
||||
Check.True(NetworkComparison.SameConfiguration(baseline, harness.Backend.Current), "RestoreOnExit should restore the original direct configuration");
|
||||
Check.Equal(4, harness.Backend.Writes.Count, "Stop should write baseline routes and DNS once");
|
||||
Check.Equal(ControlPolicy.Paused, harness.Controller.Status.Policy, "Stop restore should leave automatic control paused");
|
||||
Check.Equal(ControlPolicy.Paused, harness.Store.Read<AppSettings>("settings.json")?.Policy, "Stop restore should persist paused policy");
|
||||
Check.Equal<SwitchJournal?>(null, harness.Store.Read<SwitchJournal>(SwitchService.JournalFile), "Stop restore should clear its journal");
|
||||
var session = harness.Store.Read<SessionState>("session.json");
|
||||
Check.True(session is not null, "Stop restore should persist the updated session");
|
||||
Check.True(NetworkComparison.SameConfiguration(baseline, session!.Expected), "Stopped session should record the restored configuration");
|
||||
}
|
||||
|
||||
public static async Task StopRestoresPersistedSessionBeforeFirstRefreshAsync()
|
||||
{
|
||||
var baseline = Fixtures.Baseline();
|
||||
using var harness = new ControllerHarness(baseline);
|
||||
var settings = Fixtures.Settings();
|
||||
await SwitchToProxyAsync(harness, settings);
|
||||
await harness.Controller.SaveSettingsAsync(settings with { Policy = ControlPolicy.Paused, RestoreOnExit = true });
|
||||
Check.Equal("192.168.6.2", harness.Backend.Current.Gateway, "Fixture should persist a proxy session before restart");
|
||||
|
||||
var restarted = new GatewayController(harness.Backend, harness.Probe, harness.Store, harness.Logs.Add);
|
||||
await restarted.StopAsync();
|
||||
|
||||
Check.True(NetworkComparison.SameConfiguration(baseline, harness.Backend.Current), "RestoreOnExit must restore the persisted baseline even before the first refresh");
|
||||
}
|
||||
|
||||
public static async Task StopLeavesNetworkWhenRestoreIsDisabledAsync()
|
||||
{
|
||||
var baseline = Fixtures.Baseline();
|
||||
using var harness = new ControllerHarness(baseline);
|
||||
var settings = Fixtures.Settings();
|
||||
await SwitchToProxyAsync(harness, settings);
|
||||
await harness.Controller.SaveSettingsAsync(settings with { Policy = ControlPolicy.Paused, RestoreOnExit = false });
|
||||
var writesBeforeStop = harness.Backend.Writes.Count;
|
||||
|
||||
await harness.Controller.StopAsync();
|
||||
|
||||
Check.Equal(writesBeforeStop, harness.Backend.Writes.Count, "Stop must not restore when RestoreOnExit is false");
|
||||
Check.Equal("192.168.6.2", harness.Backend.Current.Gateway, "The proxy configuration should remain active");
|
||||
Check.Equal(false, harness.Store.Read<AppSettings>("settings.json")?.RestoreOnExit, "RestoreOnExit should remain disabled");
|
||||
Check.Equal<SwitchJournal?>(null, harness.Store.Read<SwitchJournal>(SwitchService.JournalFile), "A normal stop should leave no journal");
|
||||
}
|
||||
|
||||
public static async Task StopRestoreFailureLeavesControllerAvailableAsync()
|
||||
{
|
||||
var baseline = Fixtures.Baseline();
|
||||
using var harness = new ControllerHarness(baseline);
|
||||
var settings = Fixtures.Settings();
|
||||
await SwitchToProxyAsync(harness, settings);
|
||||
await harness.Controller.SaveSettingsAsync(settings with { Policy = ControlPolicy.Paused, RestoreOnExit = true });
|
||||
var failedAutoWrite = 0;
|
||||
harness.Backend.DnsWriteBehavior = desired =>
|
||||
{
|
||||
if (desired.Automatic && Interlocked.Increment(ref failedAutoWrite) == 1)
|
||||
throw new IOException("Injected restore DNS failure");
|
||||
harness.Backend.ApplyDns(desired);
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
|
||||
await Check.ThrowsAsync<IOException>(
|
||||
() => harness.Controller.StopAsync(),
|
||||
"A failed restore during stop should be reported");
|
||||
Check.Equal("192.168.6.2", harness.Backend.Current.Gateway, "Failed stop restore should compensate to the pre-stop proxy state");
|
||||
Check.Equal<SwitchJournal?>(null, harness.Store.Read<SwitchJournal>(SwitchService.JournalFile), "Successful compensation should clear the journal");
|
||||
|
||||
harness.Backend.DnsWriteBehavior = null;
|
||||
var probesBefore = harness.Probe.CheckCount;
|
||||
await harness.Controller.RefreshAsync();
|
||||
Check.Equal(probesBefore + 1, harness.Probe.CheckCount, "A failed stop restore must leave the controller running");
|
||||
Check.Equal(NetworkMode.Proxy, harness.Controller.Status.Mode, "Compensation should leave the controller on the proxy configuration");
|
||||
|
||||
await harness.Controller.StopAsync();
|
||||
Check.True(NetworkComparison.SameConfiguration(baseline, harness.Backend.Current), "A retried stop should restore successfully");
|
||||
}
|
||||
|
||||
private static async Task SwitchToProxyAsync(ControllerHarness harness, AppSettings settings)
|
||||
{
|
||||
harness.Probe.VerifyResult = true;
|
||||
await harness.Controller.SaveSettingsAsync(settings with { Policy = ControlPolicy.Paused });
|
||||
await harness.Controller.SetPolicyAsync(ControlPolicy.ForceProxy);
|
||||
Check.Equal("192.168.6.2", harness.Backend.Current.Gateway, "Fake switch should reach the proxy gateway");
|
||||
Check.Equal(2, harness.Backend.Writes.Count, "Fake proxy setup should write routes and DNS");
|
||||
}
|
||||
|
||||
private sealed class ControllerHarness : IDisposable
|
||||
{
|
||||
public ControllerHarness(NetworkSnapshot initial, AppSettings? persistedSettings = null)
|
||||
{
|
||||
Store = new JsonStore(Temp.DirectoryPath);
|
||||
if (persistedSettings is not null) Store.Write("settings.json", persistedSettings);
|
||||
Backend = new FakeBackend(initial);
|
||||
Probe = new FakeProbe();
|
||||
Controller = new GatewayController(Backend, Probe, Store, Logs.Add);
|
||||
}
|
||||
|
||||
public TempDirectory Temp { get; } = new();
|
||||
public FakeBackend Backend { get; }
|
||||
public FakeProbe Probe { get; }
|
||||
public JsonStore Store { get; }
|
||||
public GatewayController Controller { get; }
|
||||
public List<string> Logs { get; } = [];
|
||||
public string FilePath(string name) => Path.Combine(Temp.DirectoryPath, name);
|
||||
|
||||
public void Dispose() => Temp.Dispose();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user