diff --git a/CHANGELOG.md b/CHANGELOG.md index ef1f16e..5b30fef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +## [1.20.5] - 2026-06-08 + +### Added +- **Undo a DNS change.** Applying a DNS preset now snapshots the servers in effect beforehand, and a new "Undo" button on the DNS & Hosts tab restores that exact previous configuration (re-applying the prior static servers, or resetting to DHCP if that was the prior state). Previously the only way back was "Reset to DHCP", which silently discarded any manually-configured DNS. + ## [1.20.4] - 2026-06-08 ### Fixed diff --git a/README.md b/README.md index 011e872..dd46a94 100644 --- a/README.md +++ b/README.md @@ -252,7 +252,8 @@ deleting anything (uses the standard `LegacyDisable` registry mechanism): ### DNS & Hosts - **DNS Preset Switching** — one-click DNS change: Google, Cloudflare, Quad9, - OpenDNS, or reset to automatic (DHCP). Shows current active DNS. + OpenDNS, or reset to automatic (DHCP). Shows current active DNS. An **Undo** + button restores the exact DNS configuration in effect before the last change. - **Hosts File Editor** — view, add, and remove entries from the Windows hosts file with a clean table UI. Add IP + hostname pairs, toggle entries, or remove them. Backs up hosts file before modifications. diff --git a/SysManager/SysManager.Tests/DnsServiceTests.cs b/SysManager/SysManager.Tests/DnsServiceTests.cs index 6522851..dcf6360 100644 --- a/SysManager/SysManager.Tests/DnsServiceTests.cs +++ b/SysManager/SysManager.Tests/DnsServiceTests.cs @@ -156,4 +156,110 @@ public void GetPresets_GoogleHasExpectedAddresses() Assert.Equal("8.8.8.8", google.Primary); Assert.Equal("8.8.4.4", google.Secondary); } + + // ---------- snapshot / restore (reversibility, #3) ---------- + + [Fact] + public async Task CaptureCurrentServersAsync_ReturnsParsedAddresses() + { + var runner = Substitute.For(); + runner.RunAsync(Arg.Any(), Arg.Any?>(), Arg.Any()) + .Returns(new Collection + { + PSObject.AsPSObject("8.8.8.8"), + PSObject.AsPSObject("8.8.4.4"), + }); + using var svc = new DnsService(runner); + + var snapshot = await svc.CaptureCurrentServersAsync(); + + Assert.Equal(["8.8.8.8", "8.8.4.4"], snapshot); + } + + [Fact] + public async Task CaptureCurrentServersAsync_FiltersNonIpNoise() + { + var runner = Substitute.For(); + runner.RunAsync(Arg.Any(), Arg.Any?>(), Arg.Any()) + .Returns(new Collection + { + PSObject.AsPSObject("1.1.1.1"), + PSObject.AsPSObject(""), // blank line + PSObject.AsPSObject("garbage"),// non-IP noise + }); + using var svc = new DnsService(runner); + + var snapshot = await svc.CaptureCurrentServersAsync(); + + Assert.Equal(["1.1.1.1"], snapshot); + } + + [Fact] + public async Task CaptureCurrentServersAsync_Dhcp_ReturnsEmpty() + { + var runner = Substitute.For(); + runner.RunAsync(Arg.Any(), Arg.Any?>(), Arg.Any()) + .Returns(new Collection()); + using var svc = new DnsService(runner); + + var snapshot = await svc.CaptureCurrentServersAsync(); + + Assert.Empty(snapshot); + } + + [Fact] + public async Task RestoreServersAsync_WithAddresses_ReAppliesThem() + { + var runner = Substitute.For(); + runner.RunAsync(Arg.Any(), Arg.Any?>(), Arg.Any()) + .Returns(Result("7")); // interface index lookup + using var svc = new DnsService(runner); + + await svc.RestoreServersAsync(["9.9.9.9", "149.112.112.112"]); + + await runner.Received(1).RunAsync( + Arg.Is(s => + s.Contains("Set-DnsClientServerAddress") && + s.Contains("-InterfaceIndex 7") && + s.Contains("9.9.9.9") && + s.Contains("149.112.112.112")), + Arg.Any?>(), + Arg.Any()); + } + + [Fact] + public async Task RestoreServersAsync_EmptySnapshot_ResetsToDhcp() + { + var runner = Substitute.For(); + runner.RunAsync(Arg.Any(), Arg.Any?>(), Arg.Any()) + .Returns(Result("7")); + using var svc = new DnsService(runner); + + await svc.RestoreServersAsync([]); + + await runner.Received(1).RunAsync( + Arg.Is(s => s.Contains("-ResetServerAddresses")), + Arg.Any?>(), + Arg.Any()); + } + + [Fact] + public async Task RestoreServersAsync_NullSnapshot_Throws() + { + using var svc = new DnsService(Substitute.For()); + + await Assert.ThrowsAsync(() => svc.RestoreServersAsync(null!)); + } + + [Fact] + public async Task RestoreServersAsync_InvalidAddressInSnapshot_Throws() + { + var runner = Substitute.For(); + using var svc = new DnsService(runner); + + await Assert.ThrowsAsync(() => svc.RestoreServersAsync(["8.8.8.8", "not-an-ip"])); + + // Validation happens before any interface lookup or Set script runs. + await runner.DidNotReceiveWithAnyArgs().RunAsync(default!, default, default); + } } diff --git a/SysManager/SysManager/Services/DnsService.cs b/SysManager/SysManager/Services/DnsService.cs index ec9fa34..b36d0e3 100644 --- a/SysManager/SysManager/Services/DnsService.cs +++ b/SysManager/SysManager/Services/DnsService.cs @@ -81,6 +81,73 @@ private async Task GetActiveInterfaceIndexAsync(CancellationToken ct) throw new InvalidOperationException("No active network adapter found."); } + /// + /// Captures the current IPv4 DNS server addresses of the active adapter so a + /// change can be reverted to the exact previous configuration. Returns an empty + /// list when the adapter is on automatic (DHCP) — restoring that snapshot resets + /// to DHCP rather than re-applying static servers. + /// + public async Task> CaptureCurrentServersAsync(CancellationToken ct = default) + { + await _gate.WaitAsync(ct).ConfigureAwait(false); + try + { + const string script = """ + $adapter = Get-NetAdapter | Where-Object { $_.Status -eq 'Up' } | Sort-Object -Property ifIndex | Select-Object -First 1 + if ($adapter) { + $dns = Get-DnsClientServerAddress -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 + $dns.ServerAddresses + } + """; + + Collection results = await _ps.RunAsync(script, cancellationToken: ct) + .ConfigureAwait(false); + + return results + .Select(r => r?.ToString()) + .Where(s => !string.IsNullOrWhiteSpace(s) && IPAddress.TryParse(s, out _)) + .Select(s => s!) + .ToList(); + } + finally { _gate.Release(); } + } + + /// + /// Restores DNS to a previously captured set of server addresses. An empty + /// snapshot means the adapter was on DHCP, so this resets to automatic. + /// + public async Task RestoreServersAsync(IReadOnlyList servers, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(servers); + + if (servers.Count == 0) + { + await ResetToDhcpAsync(ct).ConfigureAwait(false); + return; + } + + // Validate every captured address before applying — the snapshot should + // already be clean, but never interpolate an unvalidated value into a script. + foreach (var server in servers) + { + if (!IPAddress.TryParse(server, out _)) + throw new ArgumentException($"Invalid DNS address in snapshot: '{server}'", nameof(servers)); + } + + await _gate.WaitAsync(ct).ConfigureAwait(false); + try + { + int ifIndex = await GetActiveInterfaceIndexAsync(ct).ConfigureAwait(false); + string joined = string.Join(",", servers.Select(s => $"\"{s}\"")); + string script = $""" + Set-DnsClientServerAddress -InterfaceIndex {ifIndex} -ServerAddresses @({joined}) + """; + + await _ps.RunAsync(script, cancellationToken: ct).ConfigureAwait(false); + } + finally { _gate.Release(); } + } + /// /// Sets the DNS server addresses on the active network adapter. /// diff --git a/SysManager/SysManager/SysManager.csproj b/SysManager/SysManager/SysManager.csproj index bd9affb..c9b9b95 100644 --- a/SysManager/SysManager/SysManager.csproj +++ b/SysManager/SysManager/SysManager.csproj @@ -10,9 +10,9 @@ SysManager true NU1603;NU1701 - 1.20.4 - 1.20.4.0 - 1.20.4.0 + 1.20.5 + 1.20.5.0 + 1.20.5.0 SysManager SysManager — Windows system monitoring toolkit by laurentiu021. Network, updates, health, logs, safe deep cleanup. https://github.com/laurentiu021/SystemManager diff --git a/SysManager/SysManager/ViewModels/DnsHostsViewModel.cs b/SysManager/SysManager/ViewModels/DnsHostsViewModel.cs index cb5c05d..27f04fd 100644 --- a/SysManager/SysManager/ViewModels/DnsHostsViewModel.cs +++ b/SysManager/SysManager/ViewModels/DnsHostsViewModel.cs @@ -31,6 +31,15 @@ public sealed partial class DnsHostsViewModel : ViewModelBase [ObservableProperty] private string _currentDns = "Loading..."; [ObservableProperty] private bool _isDnsApplying; + /// + /// The DNS servers in effect immediately before the last SysManager-applied + /// change, captured so the change can be reverted to the exact previous state. + /// Null until a change is applied this session. + /// + private IReadOnlyList? _previousServers; + + [ObservableProperty] private bool _canRestorePreviousDns; + // ── Hosts section ──────────────────────────────────────────────────── public BulkObservableCollection HostEntries { get; } = new(); @@ -134,9 +143,16 @@ private async Task ApplyDnsAsync() StatusMessage = $"Applying {SelectedPreset.Name} DNS..."; try { + // Snapshot the servers in effect now so the change is reversible to the + // exact previous configuration, not just a generic DHCP reset. + var snapshot = await _dnsService.CaptureCurrentServersAsync(_cts.Token).ConfigureAwait(false); + await _dnsService.SetDnsAsync(SelectedPreset.Primary, SelectedPreset.Secondary, _cts.Token) .ConfigureAwait(false); + _previousServers = snapshot; + Application.Current?.Dispatcher?.Invoke(() => CanRestorePreviousDns = true); + await RefreshDnsAsync(); Application.Current?.Dispatcher?.Invoke(() => StatusMessage = $"DNS set to {SelectedPreset.Name} ({SelectedPreset.Primary}, {SelectedPreset.Secondary})."); @@ -188,6 +204,61 @@ private async Task ResetDnsAsync() } } + [RelayCommand] + private async Task RestorePreviousDnsAsync() + { + if (!IsElevated) + { + StatusMessage = "Restoring DNS requires administrator privileges."; + return; + } + + if (_previousServers is null) + { + StatusMessage = "No previous DNS to restore."; + return; + } + + var label = _previousServers.Count == 0 + ? "automatic (DHCP)" + : string.Join(", ", _previousServers); + + if (!DialogService.Instance.Confirm( + $"Restore this PC's DNS to its previous setting ({label})?", + "Confirm DNS Restore")) + { + StatusMessage = "DNS restore cancelled."; + return; + } + + IsDnsApplying = true; + StatusMessage = "Restoring previous DNS..."; + try + { + await _dnsService.RestoreServersAsync(_previousServers, _cts.Token).ConfigureAwait(false); + + _previousServers = null; + Application.Current?.Dispatcher?.Invoke(() => + { + CanRestorePreviousDns = false; + StatusMessage = $"DNS restored to previous setting ({label})."; + }); + await RefreshDnsAsync(); + Log.Information("DNS restored to previous setting ({Label})", label); + } + catch (OperationCanceledException) { } + catch (Exception ex) + { + Application.Current?.Dispatcher?.Invoke(() => + StatusMessage = $"Failed to restore DNS: {ex.Message}"); + Log.Error(ex, "Failed to restore previous DNS"); + } + finally + { + Application.Current?.Dispatcher?.Invoke(() => IsDnsApplying = false); + } + } + // ── Hosts Commands ─────────────────────────────────────────────────── [RelayCommand] diff --git a/SysManager/SysManager/Views/DnsHostsView.xaml b/SysManager/SysManager/Views/DnsHostsView.xaml index 161ab11..2286ce1 100644 --- a/SysManager/SysManager/Views/DnsHostsView.xaml +++ b/SysManager/SysManager/Views/DnsHostsView.xaml @@ -69,6 +69,12 @@ Style="{StaticResource PrimaryButton}" DockPanel.Dock="Right" Padding="18,8" Margin="8,0,0,0" IsEnabled="{Binding IsDnsApplying, Converter={StaticResource BoolInvert}}"/> +