Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
106 changes: 106 additions & 0 deletions SysManager/SysManager.Tests/DnsServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<IPowerShellRunner>();
runner.RunAsync(Arg.Any<string>(), Arg.Any<IDictionary<string, object?>?>(), Arg.Any<CancellationToken>())
.Returns(new Collection<PSObject>
{
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<IPowerShellRunner>();
runner.RunAsync(Arg.Any<string>(), Arg.Any<IDictionary<string, object?>?>(), Arg.Any<CancellationToken>())
.Returns(new Collection<PSObject>
{
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<IPowerShellRunner>();
runner.RunAsync(Arg.Any<string>(), Arg.Any<IDictionary<string, object?>?>(), Arg.Any<CancellationToken>())
.Returns(new Collection<PSObject>());
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<IPowerShellRunner>();
runner.RunAsync(Arg.Any<string>(), Arg.Any<IDictionary<string, object?>?>(), Arg.Any<CancellationToken>())
.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<string>(s =>
s.Contains("Set-DnsClientServerAddress") &&
s.Contains("-InterfaceIndex 7") &&
s.Contains("9.9.9.9") &&
s.Contains("149.112.112.112")),
Arg.Any<IDictionary<string, object?>?>(),
Arg.Any<CancellationToken>());
}

[Fact]
public async Task RestoreServersAsync_EmptySnapshot_ResetsToDhcp()
{
var runner = Substitute.For<IPowerShellRunner>();
runner.RunAsync(Arg.Any<string>(), Arg.Any<IDictionary<string, object?>?>(), Arg.Any<CancellationToken>())
.Returns(Result("7"));
using var svc = new DnsService(runner);

await svc.RestoreServersAsync([]);

await runner.Received(1).RunAsync(
Arg.Is<string>(s => s.Contains("-ResetServerAddresses")),
Arg.Any<IDictionary<string, object?>?>(),
Arg.Any<CancellationToken>());
}

[Fact]
public async Task RestoreServersAsync_NullSnapshot_Throws()
{
using var svc = new DnsService(Substitute.For<IPowerShellRunner>());

await Assert.ThrowsAsync<ArgumentNullException>(() => svc.RestoreServersAsync(null!));
}

[Fact]
public async Task RestoreServersAsync_InvalidAddressInSnapshot_Throws()
{
var runner = Substitute.For<IPowerShellRunner>();
using var svc = new DnsService(runner);

await Assert.ThrowsAsync<ArgumentException>(() => 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);
}
}
67 changes: 67 additions & 0 deletions SysManager/SysManager/Services/DnsService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,73 @@
throw new InvalidOperationException("No active network adapter found.");
}

/// <summary>
/// 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.
/// </summary>
public async Task<IReadOnlyList<string>> 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<PSObject> 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(); }
}

/// <summary>
/// Restores DNS to a previously captured set of server addresses. An empty
/// snapshot means the adapter was on DHCP, so this resets to automatic.
/// </summary>
public async Task RestoreServersAsync(IReadOnlyList<string> 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));
}

Check notice

Code scanning / CodeQL

Missed opportunity to use Where Note

This foreach loop
implicitly filters its target sequence
- consider filtering the sequence explicitly using '.Where(...)'.
Comment on lines +131 to +135

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(); }
}

/// <summary>
/// Sets the DNS server addresses on the active network adapter.
/// </summary>
Expand Down
6 changes: 3 additions & 3 deletions SysManager/SysManager/SysManager.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@
<RootNamespace>SysManager</RootNamespace>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<NoWarn>NU1603;NU1701</NoWarn>
<Version>1.20.4</Version>
<FileVersion>1.20.4.0</FileVersion>
<AssemblyVersion>1.20.4.0</AssemblyVersion>
<Version>1.20.5</Version>
<FileVersion>1.20.5.0</FileVersion>
<AssemblyVersion>1.20.5.0</AssemblyVersion>
<Product>SysManager</Product>
<Description>SysManager — Windows system monitoring toolkit by laurentiu021. Network, updates, health, logs, safe deep cleanup.</Description>
<PackageProjectUrl>https://github.com/laurentiu021/SystemManager</PackageProjectUrl>
Expand Down
71 changes: 71 additions & 0 deletions SysManager/SysManager/ViewModels/DnsHostsViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,15 @@
[ObservableProperty] private string _currentDns = "Loading...";
[ObservableProperty] private bool _isDnsApplying;

/// <summary>
/// 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.
/// </summary>
private IReadOnlyList<string>? _previousServers;

[ObservableProperty] private bool _canRestorePreviousDns;

// ── Hosts section ────────────────────────────────────────────────────

public BulkObservableCollection<HostsEntry> HostEntries { get; } = new();
Expand Down Expand Up @@ -134,9 +143,16 @@
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}).");
Expand Down Expand Up @@ -188,6 +204,61 @@
}
}

[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) { }

Check notice

Code scanning / CodeQL

Poor error handling: empty catch block Note

Poor error handling: empty catch block.
catch (Exception ex)
{
Application.Current?.Dispatcher?.Invoke(() =>
StatusMessage = $"Failed to restore DNS: {ex.Message}");
Log.Error(ex, "Failed to restore previous DNS");
}

Check notice

Code scanning / CodeQL

Generic catch clause Note

Generic catch clause.
Comment on lines +250 to +255
finally
{
Application.Current?.Dispatcher?.Invoke(() => IsDnsApplying = false);
}
}

// ── Hosts Commands ───────────────────────────────────────────────────

[RelayCommand]
Expand Down
6 changes: 6 additions & 0 deletions SysManager/SysManager/Views/DnsHostsView.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,12 @@
Style="{StaticResource PrimaryButton}" DockPanel.Dock="Right"
Padding="18,8" Margin="8,0,0,0"
IsEnabled="{Binding IsDnsApplying, Converter={StaticResource BoolInvert}}"/>
<Button Content="Undo" Command="{Binding RestorePreviousDnsCommand}"
Style="{StaticResource SecondaryButton}" DockPanel.Dock="Right"
Padding="14,8" Margin="8,0,0,0"
Visibility="{Binding CanRestorePreviousDns, Converter={StaticResource BoolToVis}}"
IsEnabled="{Binding IsDnsApplying, Converter={StaticResource BoolInvert}}"
AutomationProperties.Name="Undo DNS change, restore previous DNS servers"/>
<ComboBox ItemsSource="{Binding Presets}"
SelectedItem="{Binding SelectedPreset}"
DisplayMemberPath="Name"
Expand Down
Loading