From 10eb96443e3cdde9420c7572842df3f472eda8da Mon Sep 17 00:00:00 2001 From: Waldek Mastykarz Date: Thu, 5 Nov 2026 11:47:23 +0100 Subject: [PATCH 1/6] Add 'devproxy system-proxy reset' and restore previous system proxy settings Dev Proxy now records the OS proxy settings it is about to replace (Windows ProxyEnable/ProxyServer, macOS networksetup web/secure web proxies) together with the owning PID, and restores them when it stops instead of just turning the system proxy off. Because the snapshot outlives the process, a killed or crashed instance (foreground or detached) can be recovered: - 'devproxy system-proxy reset' restores the settings on demand - Dev Proxy restores a stale registration automatically on startup Closes dotnet/dev-proxy#2060 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Proxy/ISystemProxyManager.cs | 6 +- DevProxy.Tests/SystemProxyManagerTests.cs | 119 +++++++ DevProxy/Commands/DevProxyCommand.cs | 1 + DevProxy/Commands/StopCommand.cs | 10 +- DevProxy/Commands/SystemProxyCommand.cs | 78 +++++ DevProxy/Program.cs | 37 +- DevProxy/Proxy/SystemProxyManager.cs | 315 ++++++++++++++++-- DevProxy/Proxy/SystemProxySnapshot.cs | 204 ++++++++++++ DevProxy/State/StateManager.cs | 2 +- 9 files changed, 720 insertions(+), 52 deletions(-) create mode 100644 DevProxy/Commands/SystemProxyCommand.cs create mode 100644 DevProxy/Proxy/SystemProxySnapshot.cs diff --git a/DevProxy.Abstractions/Proxy/ISystemProxyManager.cs b/DevProxy.Abstractions/Proxy/ISystemProxyManager.cs index d29269a3a..f31fde62a 100644 --- a/DevProxy.Abstractions/Proxy/ISystemProxyManager.cs +++ b/DevProxy.Abstractions/Proxy/ISystemProxyManager.cs @@ -25,8 +25,10 @@ public interface ISystemProxyManager void Enable(string? ipAddress, int port); /// - /// Removes Dev Proxy as the system proxy, restoring direct connections. Safe to call - /// even if the proxy was never enabled (idempotent best-effort cleanup). + /// Removes Dev Proxy as the system proxy, restoring the settings that were in place + /// before when they were recorded, or direct + /// connections otherwise. Safe to call even if the proxy was never enabled + /// (idempotent best-effort cleanup). /// void Disable(); } diff --git a/DevProxy.Tests/SystemProxyManagerTests.cs b/DevProxy.Tests/SystemProxyManagerTests.cs index 659998d14..9ea84fd3e 100644 --- a/DevProxy.Tests/SystemProxyManagerTests.cs +++ b/DevProxy.Tests/SystemProxyManagerTests.cs @@ -24,3 +24,122 @@ public void CreateToggleScriptStartInfo_PreservesPathAndArgumentsContainingSpace startInfo.ArgumentList); } } + +public class MacOSNetworkSetupTests +{ + [Fact] + public void ParseNetworkServices_SkipsHeaderBlankAndDisabledServices() + { + var output = "An asterisk (*) denotes that a network service is disabled.\nWi-Fi\n*Thunderbolt Bridge\nUSB 10/100/1000 LAN\n\n"; + + var services = MacOSNetworkSetup.ParseNetworkServices(output); + + Assert.Equal(["Wi-Fi", "USB 10/100/1000 LAN"], services); + } + + [Fact] + public void ParseProxyEndpoint_ReadsEnabledServerAndPort() + { + var output = "Enabled: Yes\nServer: proxy.contoso.com\nPort: 3128\nAuthenticated Proxy Enabled: 0\n"; + + var endpoint = MacOSNetworkSetup.ParseProxyEndpoint(output); + + Assert.True(endpoint.Enabled); + Assert.Equal("proxy.contoso.com", endpoint.Server); + Assert.Equal(3128, endpoint.Port); + } + + [Fact] + public void ParseProxyEndpoint_EmptyServer() + { + var endpoint = MacOSNetworkSetup.ParseProxyEndpoint("Enabled: No\nServer: \nPort: 0\n"); + + Assert.False(endpoint.Enabled); + Assert.Equal(string.Empty, endpoint.Server); + Assert.Equal(0, endpoint.Port); + } + + [Fact] + public void GetRestoreCommands_RestoresServerAndTurnsDisabledEndpointsOff() + { + var settings = new MacOSServiceProxySettings + { + Service = "Wi-Fi", + Web = new() { Enabled = true, Server = "proxy.contoso.com", Port = 3128 }, + SecureWeb = new() { Enabled = false, Server = "secure.contoso.com", Port = 3129 }, + }; + + var commands = MacOSNetworkSetup.GetRestoreCommands(settings); + + Assert.Equal(3, commands.Count); + Assert.Equal(["-setwebproxy", "Wi-Fi", "proxy.contoso.com", "3128"], commands[0]); + Assert.Equal(["-setsecurewebproxy", "Wi-Fi", "secure.contoso.com", "3129"], commands[1]); + Assert.Equal(["-setsecurewebproxystate", "Wi-Fi", "off"], commands[2]); + } + + [Fact] + public void GetRestoreCommands_NoPreviousServer_ReturnsNoCommands() + { + var settings = new MacOSServiceProxySettings + { + Service = "Wi-Fi", + Web = new() { Enabled = false, Server = string.Empty, Port = 0 }, + SecureWeb = null, + }; + + Assert.Empty(MacOSNetworkSetup.GetRestoreCommands(settings)); + } +} + +public class SystemProxySnapshotStoreTests +{ + [Fact] + public void SaveLoadDelete_RoundTripsSnapshot() + { + var folder = Directory.CreateTempSubdirectory("devproxy-snapshot-"); + try + { + var path = Path.Combine(folder.FullName, "system-proxy-backup.json"); + var snapshot = new SystemProxySnapshot + { + Pid = 1234, + CreatedAt = DateTimeOffset.UnixEpoch, + Windows = new() { ProxyEnable = 1, ProxyServer = "corp:8080" }, + MacOS = [new() { Service = "Wi-Fi", Web = new() { Enabled = true, Server = "corp", Port = 8080 } }], + }; + + SystemProxySnapshotStore.Save(path, snapshot); + var loaded = SystemProxySnapshotStore.Load(path); + + Assert.NotNull(loaded); + Assert.Equal(1234, loaded.Pid); + Assert.Equal(1, loaded.Windows?.ProxyEnable); + Assert.Equal("corp:8080", loaded.Windows?.ProxyServer); + var service = Assert.Single(loaded.MacOS!); + Assert.Equal("Wi-Fi", service.Service); + Assert.Equal(8080, service.Web?.Port); + + SystemProxySnapshotStore.Delete(path); + Assert.Null(SystemProxySnapshotStore.Load(path)); + } + finally + { + folder.Delete(recursive: true); + } + } + + [Fact] + public void Load_CorruptFile_ReturnsNull() + { + var path = Path.GetTempFileName(); + try + { + File.WriteAllText(path, "{ not json"); + Assert.Null(SystemProxySnapshotStore.Load(path)); + } + finally + { + File.Delete(path); + } + } +} diff --git a/DevProxy/Commands/DevProxyCommand.cs b/DevProxy/Commands/DevProxyCommand.cs index 6577c0bcb..b109a576b 100644 --- a/DevProxy/Commands/DevProxyCommand.cs +++ b/DevProxy/Commands/DevProxyCommand.cs @@ -636,6 +636,7 @@ private void ConfigureCommand(DevProxyConfigOptions configOptions) ActivatorUtilities.CreateInstance(_serviceProvider), ActivatorUtilities.CreateInstance(_serviceProvider), ActivatorUtilities.CreateInstance(_serviceProvider), + ActivatorUtilities.CreateInstance(_serviceProvider), ActivatorUtilities.CreateInstance(_serviceProvider) }; var pluginCommands = _plugins.SelectMany(p => p.GetCommands()); diff --git a/DevProxy/Commands/StopCommand.cs b/DevProxy/Commands/StopCommand.cs index f2ae03036..0d8c3636a 100644 --- a/DevProxy/Commands/StopCommand.cs +++ b/DevProxy/Commands/StopCommand.cs @@ -45,7 +45,7 @@ private async Task RunAsync(ParseResult parseResult, CancellationToken canc var reconciliation = await SystemProxyManager.ReconcileOrphanedSystemProxiesAsync(cancellationToken); var states = await StateManager.LoadAllStatesAsync(cancellationToken); - if (states.Count == 0 && reconciliation.Orphans.Count == 0) + if (states.Count == 0 && reconciliation.OrphanedPids.Count == 0) { Console.WriteLine("Dev Proxy is not running."); return 1; @@ -102,11 +102,11 @@ private static async Task StopByPidAsync(int pid, bool force, CancellationT private static void ReportReconciledOrphans(SystemProxyManager.OrphanReconciliation reconciliation) { - foreach (var orphan in reconciliation.Orphans) + foreach (var pid in reconciliation.OrphanedPids) { - Console.WriteLine(reconciliation.SystemProxyDisabled - ? $"Restored system proxy left by crashed Dev Proxy (PID: {orphan.Pid})." - : $"Removed stale record for crashed Dev Proxy (PID: {orphan.Pid}); system proxy is owned by a running instance."); + Console.WriteLine(reconciliation.SystemProxyRestored + ? $"Restored system proxy left by crashed Dev Proxy (PID: {pid})." + : $"Removed stale record for crashed Dev Proxy (PID: {pid}); system proxy is owned by a running instance."); } } diff --git a/DevProxy/Commands/SystemProxyCommand.cs b/DevProxy/Commands/SystemProxyCommand.cs new file mode 100644 index 000000000..d0af867e2 --- /dev/null +++ b/DevProxy/Commands/SystemProxyCommand.cs @@ -0,0 +1,78 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using DevProxy.Proxy; +using DevProxy.State; +using System.CommandLine; + +namespace DevProxy.Commands; + +sealed class SystemProxyCommand : Command +{ + private readonly ILogger _systemProxyLogger; + private readonly Option _forceOption = new("--force", "-f") + { + Description = "Reset the system proxy even if a running Dev Proxy instance owns it" + }; + + public SystemProxyCommand(ILogger systemProxyLogger) : + base("system-proxy", "Manage the system proxy settings changed by Dev Proxy") + { + _systemProxyLogger = systemProxyLogger; + + var resetCommand = new Command("reset", "Restore the system proxy settings that Dev Proxy changed. Use after Dev Proxy was killed or crashed."); + resetCommand.Add(_forceOption); + resetCommand.SetAction(ResetAsync); + Add(resetCommand); + + HelpExamples.Add(this, [ + "devproxy system-proxy reset Restore system proxy settings", + ]); + HelpExamples.Add(resetCommand, [ + "devproxy system-proxy reset Restore system proxy settings", + "devproxy system-proxy reset --force Restore even if Dev Proxy is running", + ]); + } + + private async Task ResetAsync(ParseResult parseResult, CancellationToken cancellationToken) + { + var force = parseResult.GetValue(_forceOption); + + var liveOwner = await SystemProxyManager.FindLiveSystemProxyOwnerAsync(cancellationToken); + if (liveOwner is not null && !force) + { + await Console.Error.WriteLineAsync($"Dev Proxy (PID: {liveOwner}) is running as the system proxy."); + await Console.Error.WriteLineAsync("Use 'devproxy stop' to stop it, or 'devproxy system-proxy reset --force' to reset anyway."); + return 1; + } + + var result = new SystemProxyManager(_systemProxyLogger).Restore(); + if (result is SystemProxyRestoreResult.Failed) + { + await Console.Error.WriteLineAsync("Couldn't restore the system proxy settings. To reset them manually, see https://go.devproxy.net?go=proxy-reset."); + return 1; + } + + // The settings are restored, so records of dead instances that registered as the + // system proxy are stale. Remove them so they don't trigger another restore later. + foreach (var orphan in await StateManager.GetOrphanedSystemProxyStatesAsync(cancellationToken)) + { + await StateManager.DeleteStateAsync(orphan.Pid, cancellationToken); + } + + switch (result) + { + case SystemProxyRestoreResult.RestoredPrevious: + Console.WriteLine("Restored the system proxy settings from before Dev Proxy started."); + return 0; + case SystemProxyRestoreResult.Disabled: + Console.WriteLine("Turned off the system proxy."); + return 0; + case SystemProxyRestoreResult.NotSupported: + default: + Console.WriteLine("Dev Proxy doesn't change the system proxy settings on this operating system. Nothing to reset."); + return 0; + } + } +} diff --git a/DevProxy/Program.cs b/DevProxy/Program.cs index ec32588f4..c968672bf 100644 --- a/DevProxy/Program.cs +++ b/DevProxy/Program.cs @@ -51,20 +51,14 @@ Console.SetError(_detachedLogWriter); } -static async Task StartDetachedProcessAsync(string[] args) +static async Task ReconcileOrphanedSystemProxiesAsync(bool isJsonOutput) { - var isJsonOutput = IsJsonOutputRequested(args); - - // Recover from a system-proxy registration left behind by a previous - // instance that crashed before restoring the OS proxy. This must run before - // any state-loading call below, which prunes (deletes) the stale state files - // it depends on without restoring the proxy. var reconciliation = await SystemProxyManager.ReconcileOrphanedSystemProxiesAsync(); - foreach (var orphan in reconciliation.Orphans) + foreach (var pid in reconciliation.OrphanedPids) { - var message = reconciliation.SystemProxyDisabled - ? $"Recovered system proxy left by a crashed Dev Proxy instance (PID: {orphan.Pid})." - : $"Removed stale system-proxy registration left by a crashed Dev Proxy instance (PID: {orphan.Pid}) while keeping the current system proxy owner unchanged."; + var message = reconciliation.SystemProxyRestored + ? $"Recovered system proxy left by a crashed Dev Proxy instance (PID: {pid})." + : $"Removed stale system-proxy registration left by a crashed Dev Proxy instance (PID: {pid}) while keeping the current system proxy owner unchanged."; if (isJsonOutput) { await Console.Out.WriteLineAsync(FormatJsonLogEntry("info", message)); @@ -74,6 +68,17 @@ static async Task StartDetachedProcessAsync(string[] args) await Console.Out.WriteLineAsync(message); } } +} + +static async Task StartDetachedProcessAsync(string[] args) +{ + var isJsonOutput = IsJsonOutputRequested(args); + + // Recover from a system-proxy registration left behind by a previous + // instance that crashed before restoring the OS proxy. This must run before + // any state-loading call below, which prunes (deletes) the stale state files + // it depends on without restoring the proxy. + await ReconcileOrphanedSystemProxiesAsync(isJsonOutput); // Check if an instance is already running as system proxy var systemProxyInstance = await StateManager.FindSystemProxyInstanceAsync(); @@ -431,6 +436,16 @@ static async Task RunProxyAsync(string[] args, DevProxyConfigOptions option return; } +// Recover from a system-proxy registration left behind by a previous instance +// that was killed or crashed. Detached daemons skip this because the parent +// process that spawned them already did it. +if (DevProxyCommand.IsRootCommand && + !DevProxyCommand.IsInternalDaemon && + !args.Any(a => a is "-h" or "-?" or "--help" or "--version")) +{ + await ReconcileOrphanedSystemProxiesAsync(IsJsonOutputRequested(args)); +} + var options = new DevProxyConfigOptions(); options.ParseOptions(args); diff --git a/DevProxy/Proxy/SystemProxyManager.cs b/DevProxy/Proxy/SystemProxyManager.cs index 3571ac42d..a7d82e961 100644 --- a/DevProxy/Proxy/SystemProxyManager.cs +++ b/DevProxy/Proxy/SystemProxyManager.cs @@ -6,6 +6,7 @@ using System.Globalization; using System.Runtime.InteropServices; using System.Runtime.Versioning; +using System.Text; using DevProxy.Abstractions.Proxy; using DevProxy.State; using Microsoft.Extensions.Logging.Abstractions; @@ -13,66 +14,175 @@ namespace DevProxy.Proxy; +/// +/// The outcome of restoring the OS proxy settings. +/// +internal enum SystemProxyRestoreResult +{ + /// The settings captured before Dev Proxy registered itself were put back. + RestoredPrevious, + /// No previous settings were recorded, so the system proxy was turned off. + Disabled, + /// Dev Proxy doesn't configure the system proxy on this OS. + NotSupported, + /// The OS settings couldn't be changed. Details are logged. + Failed, +} + /// /// Host-side . Engine-agnostic OS proxy on/off, shared by -/// the Kestrel engine and the stop --force crash-cleanup path. +/// the Kestrel engine, the stop --force crash-cleanup path, and +/// devproxy system-proxy reset. /// /// /// Enable(ip, port) Disable() /// ──────────────── ───────── -/// Windows → registry ProxyServer Windows → registry ProxyEnable = 0 -/// + ProxyEnable = 1 + WinINET refresh +/// snapshot current settings restore snapshot when present +/// Windows → registry ProxyServer Windows → registry ProxyServer/ProxyEnable +/// + ProxyEnable = 1 (or ProxyEnable = 0) + WinINET refresh /// + WinINET refresh -/// macOS → toggle-proxy.sh on … macOS → toggle-proxy.sh off +/// macOS → toggle-proxy.sh on … macOS → toggle-proxy.sh off + networksetup /// Linux → log warning Linux → no-op /// /// /// +/// Before registering, the current OS proxy settings are written to a snapshot file +/// () together with the owning PID. Restoring reads +/// that file, so the previous settings can be put back even after the owning instance was +/// killed or crashed. +/// +/// +/// /// The Windows path uses WinINET: it writes the per-user Internet Settings registry values /// and then broadcasts INTERNET_OPTION_SETTINGS_CHANGED + INTERNET_OPTION_REFRESH so running /// applications re-read the proxy without a restart. This is the standard WinINET mechanism; /// it cannot be exercised on non-Windows hosts. /// /// -internal sealed class SystemProxyManager(ILogger logger) : ISystemProxyManager +internal sealed class SystemProxyManager : ISystemProxyManager { private const string InternetSettingsKey = @"Software\Microsoft\Windows\CurrentVersion\Internet Settings"; private const int InternetOptionSettingsChanged = 39; private const int InternetOptionRefresh = 37; + private readonly ILogger _logger; + private readonly string _snapshotPath; + + public SystemProxyManager(ILogger logger) : + this(logger, SystemProxySnapshotStore.DefaultPath) + { + } + + internal SystemProxyManager(ILogger logger, string snapshotPath) + { + _logger = logger; + _snapshotPath = snapshotPath; + } + public void Enable(string? ipAddress, int port) { if (OperatingSystem.IsWindows()) { + SaveSnapshot(CaptureWindows); EnableWindows(SystemProxyAddress.ToHostPort(ipAddress, port)); } else if (OperatingSystem.IsMacOS()) { - RunToggleScript( + SaveSnapshot(CaptureMacOS); + _ = RunToggleScript( "on", SystemProxyAddress.ResolveHost(ipAddress), port.ToString(CultureInfo.InvariantCulture)); } else { - logger.LogWarning( + _logger.LogWarning( "Configure your operating system to use this proxy's port and address {Address}:{Port}", SystemProxyAddress.ResolveHost(ipAddress), port); } } - public void Disable() + public void Disable() => _ = Restore(); + + /// + /// Puts back the OS proxy settings recorded before Dev Proxy registered itself, or turns + /// the system proxy off when nothing was recorded. Safe to call when Dev Proxy never + /// changed the settings. + /// + internal SystemProxyRestoreResult Restore() { + if (!OperatingSystem.IsWindows() && !OperatingSystem.IsMacOS()) + { + return SystemProxyRestoreResult.NotSupported; + } + + var snapshot = SystemProxySnapshotStore.Load(_snapshotPath); + bool succeeded; + bool restoredPrevious; if (OperatingSystem.IsWindows()) { - DisableWindows(); + restoredPrevious = snapshot?.Windows is not null; + succeeded = restoredPrevious + ? RestoreWindows(snapshot!.Windows!) + : DisableWindows(); } - else if (OperatingSystem.IsMacOS()) + else + { + succeeded = RunToggleScript("off"); + restoredPrevious = snapshot?.MacOS is not null; + if (succeeded && restoredPrevious) + { + succeeded = RestoreMacOS(snapshot!.MacOS!); + } + } + + if (!succeeded) + { + // Keep the snapshot so a later reset can still put the previous settings back. + return SystemProxyRestoreResult.Failed; + } + + if (snapshot is not null) { - RunToggleScript("off"); + SystemProxySnapshotStore.Delete(_snapshotPath); } + return restoredPrevious + ? SystemProxyRestoreResult.RestoredPrevious + : SystemProxyRestoreResult.Disabled; + } + + private void SaveSnapshot(Action capture) + { + try + { + // Keep an existing snapshot: it holds the settings from before Dev Proxy first + // took over, while the current settings may already point at Dev Proxy. + var snapshot = SystemProxySnapshotStore.Load(_snapshotPath); + if (snapshot is null) + { + snapshot = new() { CreatedAt = DateTimeOffset.UtcNow }; + capture(snapshot); + } + snapshot.Pid = Environment.ProcessId; + SystemProxySnapshotStore.Save(_snapshotPath, snapshot); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Security.SecurityException or InvalidOperationException or System.ComponentModel.Win32Exception) + { + _logger.LogWarning(ex, "Couldn't save the current system proxy settings. They can't be restored when Dev Proxy stops."); + } + } + + [SupportedOSPlatform("windows")] + private static void CaptureWindows(SystemProxySnapshot snapshot) + { + using var key = Registry.CurrentUser.OpenSubKey(InternetSettingsKey, writable: false); + snapshot.Windows = new() + { + ProxyEnable = key?.GetValue("ProxyEnable") is int enable ? enable : null, + ProxyServer = key?.GetValue("ProxyServer") as string, + }; } [SupportedOSPlatform("windows")] @@ -83,7 +193,7 @@ private void EnableWindows(string hostPort) using var key = Registry.CurrentUser.OpenSubKey(InternetSettingsKey, writable: true); if (key is null) { - logger.LogError("Could not open the Windows Internet Settings registry key."); + _logger.LogError("Could not open the Windows Internet Settings registry key."); return; } @@ -93,27 +203,68 @@ private void EnableWindows(string hostPort) } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Security.SecurityException) { - logger.LogError(ex, "Failed to set the system proxy via the Windows registry."); + _logger.LogError(ex, "Failed to set the system proxy via the Windows registry."); } } [SupportedOSPlatform("windows")] - private void DisableWindows() + private bool DisableWindows() { try { using var key = Registry.CurrentUser.OpenSubKey(InternetSettingsKey, writable: true); if (key is null) { - return; + return false; } key.SetValue("ProxyEnable", 0, RegistryValueKind.DWord); NotifyWinInetSettingsChanged(); + return true; } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Security.SecurityException) { - logger.LogError(ex, "Failed to clear the system proxy via the Windows registry."); + _logger.LogError(ex, "Failed to clear the system proxy via the Windows registry."); + return false; + } + } + + [SupportedOSPlatform("windows")] + private bool RestoreWindows(WindowsProxySettings settings) + { + try + { + using var key = Registry.CurrentUser.OpenSubKey(InternetSettingsKey, writable: true); + if (key is null) + { + return false; + } + + if (settings.ProxyServer is null) + { + key.DeleteValue("ProxyServer", throwOnMissingValue: false); + } + else + { + key.SetValue("ProxyServer", settings.ProxyServer, RegistryValueKind.String); + } + + if (settings.ProxyEnable is null) + { + key.DeleteValue("ProxyEnable", throwOnMissingValue: false); + } + else + { + key.SetValue("ProxyEnable", settings.ProxyEnable.Value, RegistryValueKind.DWord); + } + + NotifyWinInetSettingsChanged(); + return true; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Security.SecurityException) + { + _logger.LogError(ex, "Failed to restore the system proxy via the Windows registry."); + return false; } } @@ -134,13 +285,79 @@ private static void NotifyWinInetSettingsChanged() private static extern bool InternetSetOption(IntPtr hInternet, int dwOption, IntPtr lpBuffer, int dwBufferLength); #pragma warning restore SYSLIB1054 - private void RunToggleScript(params string[] arguments) + private static void CaptureMacOS(SystemProxySnapshot snapshot) + { + var services = MacOSNetworkSetup.ParseNetworkServices( + RunNetworkSetup("-listallnetworkservices") ?? string.Empty); + snapshot.MacOS = [.. services.Select(service => new MacOSServiceProxySettings + { + Service = service, + Web = MacOSNetworkSetup.ParseProxyEndpoint(RunNetworkSetup("-getwebproxy", service) ?? string.Empty), + SecureWeb = MacOSNetworkSetup.ParseProxyEndpoint(RunNetworkSetup("-getsecurewebproxy", service) ?? string.Empty), + })]; + } + + private bool RestoreMacOS(IEnumerable services) + { + try + { + foreach (var command in services.SelectMany(MacOSNetworkSetup.GetRestoreCommands)) + { + _ = RunNetworkSetup(command); + } + return true; + } + catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or InvalidOperationException or IOException) + { + _logger.LogError(ex, "Failed to restore the system proxy via networksetup."); + return false; + } + } + + private static string? RunNetworkSetup(params string[] arguments) + { + var startInfo = new ProcessStartInfo + { + FileName = MacOSNetworkSetup.Executable, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + foreach (var argument in arguments) + { + startInfo.ArgumentList.Add(argument); + } + + using var process = new Process { StartInfo = startInfo }; + var output = new StringBuilder(); + process.OutputDataReceived += (_, e) => + { + if (e.Data is not null) + { + _ = output.AppendLine(e.Data); + } + }; + _ = process.Start(); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + if (!process.WaitForExit(TimeSpan.FromSeconds(10))) + { + process.Kill(); + return null; + } + // Flush the asynchronous output readers. + process.WaitForExit(); + return output.ToString(); + } + + private bool RunToggleScript(params string[] arguments) { var bashScriptPath = Path.Join(AppContext.BaseDirectory, "toggle-proxy.sh"); if (!File.Exists(bashScriptPath)) { - logger.LogWarning("Could not find {Script} to toggle the system proxy.", "toggle-proxy.sh"); - return; + _logger.LogWarning("Could not find {Script} to toggle the system proxy.", "toggle-proxy.sh"); + return false; } var startInfo = CreateToggleScriptStartInfo(bashScriptPath, arguments); @@ -152,11 +369,14 @@ private void RunToggleScript(params string[] arguments) if (!process.WaitForExit(TimeSpan.FromSeconds(10))) { process.Kill(); + return false; } + return true; } catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or InvalidOperationException or IOException) { - logger.LogError(ex, "Failed to toggle the system proxy via toggle-proxy.sh."); + _logger.LogError(ex, "Failed to toggle the system proxy via toggle-proxy.sh."); + return false; } } @@ -184,36 +404,65 @@ internal static ProcessStartInfo CreateToggleScriptStartInfo(string bashScriptPa /// /// The outcome of reconciling orphaned system-proxy registrations. /// + /// PIDs of dead instances that left a system-proxy registration behind. + /// Whether the OS proxy settings were restored. internal readonly record struct OrphanReconciliation( - IReadOnlyList Orphans, - bool SystemProxyDisabled); + IReadOnlyList OrphanedPids, + bool SystemProxyRestored); /// - /// Reconciles system-proxy registrations left behind by crashed instances. + /// Finds the PID of a live process that owns the system proxy: a running detached + /// instance, or the (foreground or detached) instance recorded in the snapshot file. + /// Returns null when no live process owns it. + /// + internal static async Task FindLiveSystemProxyOwnerAsync(CancellationToken cancellationToken = default) + { + var liveInstance = await StateManager.FindSystemProxyInstanceAsync(cancellationToken); + if (liveInstance is not null) + { + return liveInstance.Pid; + } + + var snapshot = SystemProxySnapshotStore.Load(SystemProxySnapshotStore.DefaultPath); + return snapshot is not null && StateManager.IsProcessRunning(snapshot.Pid) + ? snapshot.Pid + : null; + } + + /// + /// Reconciles system-proxy registrations left behind by crashed or killed instances. /// Restores the OS proxy (unless a live instance still owns it) and removes /// the stale state records. Safe to call when there are no orphans. /// public static async Task ReconcileOrphanedSystemProxiesAsync(CancellationToken cancellationToken = default) { - var orphans = await StateManager.GetOrphanedSystemProxyStatesAsync(cancellationToken); - if (orphans.Count == 0) + var orphanedPids = (await StateManager.GetOrphanedSystemProxyStatesAsync(cancellationToken)) + .Select(o => o.Pid) + .ToList(); + + var snapshot = SystemProxySnapshotStore.Load(SystemProxySnapshotStore.DefaultPath); + if (snapshot is not null && !StateManager.IsProcessRunning(snapshot.Pid) && !orphanedPids.Contains(snapshot.Pid)) + { + orphanedPids.Add(snapshot.Pid); + } + + if (orphanedPids.Count == 0) { return new([], false); } - var liveOwner = await StateManager.FindSystemProxyInstanceAsync(cancellationToken); - var disabled = false; - if (liveOwner is null) + var restored = false; + if (await FindLiveSystemProxyOwnerAsync(cancellationToken) is null) { - new SystemProxyManager(NullLogger.Instance).Disable(); - disabled = true; + restored = new SystemProxyManager(NullLogger.Instance).Restore() + is SystemProxyRestoreResult.RestoredPrevious or SystemProxyRestoreResult.Disabled; } - foreach (var orphan in orphans) + foreach (var pid in orphanedPids) { - await StateManager.DeleteStateAsync(orphan.Pid, cancellationToken); + await StateManager.DeleteStateAsync(pid, cancellationToken); } - return new(orphans, disabled); + return new(orphanedPids, restored); } } diff --git a/DevProxy/Proxy/SystemProxySnapshot.cs b/DevProxy/Proxy/SystemProxySnapshot.cs new file mode 100644 index 000000000..acb3b0cc5 --- /dev/null +++ b/DevProxy/Proxy/SystemProxySnapshot.cs @@ -0,0 +1,204 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; +using DevProxy.State; + +namespace DevProxy.Proxy; + +/// +/// The OS proxy settings that were in place before Dev Proxy registered itself as the +/// system proxy, plus the PID of the instance that did it. Persisted to disk so the +/// settings can be restored even if that instance is killed or crashes. +/// +internal sealed class SystemProxySnapshot +{ + [JsonPropertyName("pid")] + public int Pid { get; set; } + + [JsonPropertyName("createdAt")] + public DateTimeOffset CreatedAt { get; set; } + + [JsonPropertyName("windows")] + public WindowsProxySettings? Windows { get; set; } + + [JsonPropertyName("macOS")] + public List? MacOS { get; set; } +} + +internal sealed class WindowsProxySettings +{ + /// The previous ProxyEnable registry value; null when the value didn't exist. + [JsonPropertyName("proxyEnable")] + public int? ProxyEnable { get; set; } + + /// The previous ProxyServer registry value; null when the value didn't exist. + [JsonPropertyName("proxyServer")] + public string? ProxyServer { get; set; } +} + +internal sealed class MacOSServiceProxySettings +{ + [JsonPropertyName("service")] + public string Service { get; set; } = string.Empty; + + [JsonPropertyName("web")] + public MacOSProxyEndpoint? Web { get; set; } + + [JsonPropertyName("secureWeb")] + public MacOSProxyEndpoint? SecureWeb { get; set; } +} + +internal sealed class MacOSProxyEndpoint +{ + [JsonPropertyName("enabled")] + public bool Enabled { get; set; } + + [JsonPropertyName("server")] + public string Server { get; set; } = string.Empty; + + [JsonPropertyName("port")] + public int Port { get; set; } +} + +/// +/// Reads and writes the file. +/// +internal static class SystemProxySnapshotStore +{ + private const string FileName = "system-proxy-backup.json"; + + private static readonly JsonSerializerOptions _jsonOptions = new() { WriteIndented = true }; + + public static string DefaultPath => Path.Combine(StateManager.GetConfigFolder(), FileName); + + public static SystemProxySnapshot? Load(string path) + { + try + { + if (!File.Exists(path)) + { + return null; + } + + return JsonSerializer.Deserialize(File.ReadAllText(path)); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException) + { + return null; + } + } + + public static void Save(string path, SystemProxySnapshot snapshot) + { + var json = JsonSerializer.Serialize(snapshot, _jsonOptions); + // Called from the synchronous ISystemProxyManager.Enable; the host has no + // synchronization context, so blocking here can't deadlock. +#pragma warning disable VSTHRD002 + PrivateFiles.WriteAllTextAsync(path, json).GetAwaiter().GetResult(); +#pragma warning restore VSTHRD002 + } + + public static void Delete(string path) + { + try + { + File.Delete(path); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + } + } +} + +/// +/// Pure helpers for reading and restoring macOS proxy settings with networksetup. +/// +internal static class MacOSNetworkSetup +{ + public const string Executable = "/usr/sbin/networksetup"; + + /// + /// Parses networksetup -listallnetworkservices. The first line is an informational + /// header and services prefixed with * are disabled, so both are skipped. + /// + public static List ParseNetworkServices(string output) + { + return [.. output + .Split('\n') + .Skip(1) + .Select(l => l.TrimEnd('\r')) + .Where(l => !string.IsNullOrWhiteSpace(l) && !l.StartsWith('*'))]; + } + + /// + /// Parses networksetup -getwebproxy / -getsecurewebproxy output. + /// + public static MacOSProxyEndpoint ParseProxyEndpoint(string output) + { + var endpoint = new MacOSProxyEndpoint(); + foreach (var rawLine in output.Split('\n')) + { + var line = rawLine.TrimEnd('\r'); + var separator = line.IndexOf(':', StringComparison.Ordinal); + if (separator < 0) + { + continue; + } + + var key = line[..separator].Trim(); + var value = line[(separator + 1)..].Trim(); + switch (key) + { + case "Enabled": + endpoint.Enabled = value.Equals("Yes", StringComparison.OrdinalIgnoreCase); + break; + case "Server": + endpoint.Server = value; + break; + case "Port": + endpoint.Port = int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var port) ? port : 0; + break; + default: + break; + } + } + return endpoint; + } + + /// + /// Builds the networksetup argument lists that put back a service's previous + /// proxy server and state. Assumes the proxy has already been turned off for every + /// service, so endpoints without a previous server need no commands. + /// + public static List GetRestoreCommands(MacOSServiceProxySettings settings) + { + var commands = new List(); + AddEndpointCommands(commands, settings.Service, settings.Web, "-setwebproxy", "-setwebproxystate"); + AddEndpointCommands(commands, settings.Service, settings.SecureWeb, "-setsecurewebproxy", "-setsecurewebproxystate"); + return commands; + } + + private static void AddEndpointCommands( + List commands, + string service, + MacOSProxyEndpoint? endpoint, + string setProxyCommand, + string setStateCommand) + { + if (endpoint is null || string.IsNullOrEmpty(endpoint.Server) || endpoint.Port <= 0) + { + return; + } + + // Setting the server also turns the proxy on, so turn it back off if it was disabled. + commands.Add([setProxyCommand, service, endpoint.Server, endpoint.Port.ToString(CultureInfo.InvariantCulture)]); + if (!endpoint.Enabled) + { + commands.Add([setStateCommand, service, "off"]); + } + } +} diff --git a/DevProxy/State/StateManager.cs b/DevProxy/State/StateManager.cs index 5ca80d537..fa24e16da 100644 --- a/DevProxy/State/StateManager.cs +++ b/DevProxy/State/StateManager.cs @@ -288,7 +288,7 @@ public static async Task IsInstanceRunningAsync(CancellationToken cancella /// /// Checks if a process with the given PID is running. /// - private static bool IsProcessRunning(int pid) + internal static bool IsProcessRunning(int pid) { try { From e76ff0b2a0cbb33e26833b3d67dba4aae4b8e56a Mon Sep 17 00:00:00 2001 From: Waldek Mastykarz Date: Thu, 5 Nov 2026 11:54:47 +0100 Subject: [PATCH 2/6] Address review feedback on system proxy reset - Run 'system-proxy reset' before loading config/plugins - Don't let an instance's shutdown undo a forced reset or another owner - Treat networksetup timeouts/nonzero exits and toggle-proxy.sh failures as restore failures so the snapshot is kept - Skip disabled macOS services in toggle-proxy.sh and propagate failures - Report the Linux no-op before checking proxy ownership Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- DevProxy/Commands/DevProxyCommand.cs | 20 +++++++++ DevProxy/Commands/SystemProxyCommand.cs | 58 ++++++++++++++++++------- DevProxy/Program.cs | 9 ++++ DevProxy/Proxy/SystemProxyManager.cs | 39 ++++++++++++++--- DevProxy/toggle-proxy.sh | 20 ++++++--- 5 files changed, 118 insertions(+), 28 deletions(-) diff --git a/DevProxy/Commands/DevProxyCommand.cs b/DevProxy/Commands/DevProxyCommand.cs index b109a576b..af668b8f5 100644 --- a/DevProxy/Commands/DevProxyCommand.cs +++ b/DevProxy/Commands/DevProxyCommand.cs @@ -59,6 +59,7 @@ sealed class DevProxyCommand : RootCommand private static bool _isJwtCommandResolved; private static bool _isRootCommandResolved; private static bool _isConfigValidateCommandResolved; + private static bool _isSystemProxyResetCommandResolved; private static bool _isDetachedModeResolved; private static bool _isInternalDaemonResolved; private static bool _stdioLogFilePathResolved; @@ -132,6 +133,25 @@ public static bool IsConfigValidateCommand } } + public static bool IsSystemProxyResetCommand + { + get + { + if (_isSystemProxyResetCommandResolved) + { + return field; + } + + var args = Environment.GetCommandLineArgs(); + field = args.Length > 2 && + string.Equals(args[1], "system-proxy", StringComparison.OrdinalIgnoreCase) && + string.Equals(args[2], "reset", StringComparison.OrdinalIgnoreCase) && + !args.Any(arg => helpOptions.Contains(arg)); + _isSystemProxyResetCommandResolved = true; + return field; + } + } + public static bool IsDetachedMode { get diff --git a/DevProxy/Commands/SystemProxyCommand.cs b/DevProxy/Commands/SystemProxyCommand.cs index d0af867e2..623ee6a73 100644 --- a/DevProxy/Commands/SystemProxyCommand.cs +++ b/DevProxy/Commands/SystemProxyCommand.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using DevProxy.Logging; using DevProxy.Proxy; using DevProxy.State; using System.CommandLine; @@ -35,9 +36,43 @@ public SystemProxyCommand(ILogger systemProxyLogger) : ]); } - private async Task ResetAsync(ParseResult parseResult, CancellationToken cancellationToken) + private Task ResetAsync(ParseResult parseResult, CancellationToken cancellationToken) => + ResetCoreAsync(parseResult.GetValue(_forceOption), _systemProxyLogger, cancellationToken); + + /// + /// Runs system-proxy reset without building the application host, so a broken + /// configuration or plugin can't prevent restoring network access. + /// + internal static async Task RunResetStandaloneAsync(string[] args) { - var force = parseResult.GetValue(_forceOption); + var force = args.Any(a => a is "--force" or "-f"); + + using var loggerFactory = LoggerFactory.Create(builder => + { + _ = builder + .SetMinimumLevel(LogLevel.Information) + .AddConsole(consoleOptions => + { + consoleOptions.FormatterName = ProxyConsoleFormatter.DefaultCategoryName; + consoleOptions.LogToStandardErrorThreshold = LogLevel.Warning; + }) + .AddConsoleFormatter(formatterOptions => + { + formatterOptions.IncludeScopes = false; + formatterOptions.ShowTimestamps = false; + }); + }); + + return await ResetCoreAsync(force, loggerFactory.CreateLogger(), CancellationToken.None); + } + + private static async Task ResetCoreAsync(bool force, ILogger logger, CancellationToken cancellationToken) + { + if (!OperatingSystem.IsWindows() && !OperatingSystem.IsMacOS()) + { + Console.WriteLine("Dev Proxy doesn't change the system proxy settings on this operating system. Nothing to reset."); + return 0; + } var liveOwner = await SystemProxyManager.FindLiveSystemProxyOwnerAsync(cancellationToken); if (liveOwner is not null && !force) @@ -47,7 +82,7 @@ private async Task ResetAsync(ParseResult parseResult, CancellationToken ca return 1; } - var result = new SystemProxyManager(_systemProxyLogger).Restore(); + var result = new SystemProxyManager(logger).Restore(); if (result is SystemProxyRestoreResult.Failed) { await Console.Error.WriteLineAsync("Couldn't restore the system proxy settings. To reset them manually, see https://go.devproxy.net?go=proxy-reset."); @@ -61,18 +96,9 @@ private async Task ResetAsync(ParseResult parseResult, CancellationToken ca await StateManager.DeleteStateAsync(orphan.Pid, cancellationToken); } - switch (result) - { - case SystemProxyRestoreResult.RestoredPrevious: - Console.WriteLine("Restored the system proxy settings from before Dev Proxy started."); - return 0; - case SystemProxyRestoreResult.Disabled: - Console.WriteLine("Turned off the system proxy."); - return 0; - case SystemProxyRestoreResult.NotSupported: - default: - Console.WriteLine("Dev Proxy doesn't change the system proxy settings on this operating system. Nothing to reset."); - return 0; - } + Console.WriteLine(result is SystemProxyRestoreResult.RestoredPrevious + ? "Restored the system proxy settings from before Dev Proxy started." + : "Turned off the system proxy."); + return 0; } } diff --git a/DevProxy/Program.cs b/DevProxy/Program.cs index c968672bf..f19d03083 100644 --- a/DevProxy/Program.cs +++ b/DevProxy/Program.cs @@ -436,6 +436,15 @@ static async Task RunProxyAsync(string[] args, DevProxyConfigOptions option return; } +// Handle 'system-proxy reset' early so a broken configuration or plugin can't +// prevent restoring network access. +if (DevProxyCommand.IsSystemProxyResetCommand) +{ + var resetExitCode = await SystemProxyCommand.RunResetStandaloneAsync(args); + Environment.Exit(resetExitCode); + return; +} + // Recover from a system-proxy registration left behind by a previous instance // that was killed or crashed. Detached daemons skip this because the parent // process that spawned them already did it. diff --git a/DevProxy/Proxy/SystemProxyManager.cs b/DevProxy/Proxy/SystemProxyManager.cs index a7d82e961..038452b95 100644 --- a/DevProxy/Proxy/SystemProxyManager.cs +++ b/DevProxy/Proxy/SystemProxyManager.cs @@ -68,6 +68,7 @@ internal sealed class SystemProxyManager : ISystemProxyManager private readonly ILogger _logger; private readonly string _snapshotPath; + private bool _savedSnapshot; public SystemProxyManager(ILogger logger) : this(logger, SystemProxySnapshotStore.DefaultPath) @@ -104,7 +105,22 @@ public void Enable(string? ipAddress, int port) } } - public void Disable() => _ = Restore(); + public void Disable() + { + if (_savedSnapshot) + { + _savedSnapshot = false; + // The snapshot is gone or belongs to another process when 'system-proxy reset' + // already restored the settings or another instance took over. Restoring now + // would undo that, so leave the settings alone. + if (SystemProxySnapshotStore.Load(_snapshotPath)?.Pid != Environment.ProcessId) + { + return; + } + } + + _ = Restore(); + } /// /// Puts back the OS proxy settings recorded before Dev Proxy registered itself, or turns @@ -167,6 +183,7 @@ private void SaveSnapshot(Action capture) } snapshot.Pid = Environment.ProcessId; SystemProxySnapshotStore.Save(_snapshotPath, snapshot); + _savedSnapshot = true; } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Security.SecurityException or InvalidOperationException or System.ComponentModel.Win32Exception) { @@ -288,12 +305,12 @@ private static void NotifyWinInetSettingsChanged() private static void CaptureMacOS(SystemProxySnapshot snapshot) { var services = MacOSNetworkSetup.ParseNetworkServices( - RunNetworkSetup("-listallnetworkservices") ?? string.Empty); + RunNetworkSetup("-listallnetworkservices")); snapshot.MacOS = [.. services.Select(service => new MacOSServiceProxySettings { Service = service, - Web = MacOSNetworkSetup.ParseProxyEndpoint(RunNetworkSetup("-getwebproxy", service) ?? string.Empty), - SecureWeb = MacOSNetworkSetup.ParseProxyEndpoint(RunNetworkSetup("-getsecurewebproxy", service) ?? string.Empty), + Web = MacOSNetworkSetup.ParseProxyEndpoint(RunNetworkSetup("-getwebproxy", service)), + SecureWeb = MacOSNetworkSetup.ParseProxyEndpoint(RunNetworkSetup("-getsecurewebproxy", service)), })]; } @@ -314,7 +331,7 @@ private bool RestoreMacOS(IEnumerable services) } } - private static string? RunNetworkSetup(params string[] arguments) + private static string RunNetworkSetup(params string[] arguments) { var startInfo = new ProcessStartInfo { @@ -344,10 +361,14 @@ private bool RestoreMacOS(IEnumerable services) if (!process.WaitForExit(TimeSpan.FromSeconds(10))) { process.Kill(); - return null; + throw new InvalidOperationException($"networksetup {arguments[0]} timed out."); } // Flush the asynchronous output readers. process.WaitForExit(); + if (process.ExitCode != 0) + { + throw new InvalidOperationException($"networksetup {arguments[0]} exited with code {process.ExitCode}."); + } return output.ToString(); } @@ -369,6 +390,12 @@ private bool RunToggleScript(params string[] arguments) if (!process.WaitForExit(TimeSpan.FromSeconds(10))) { process.Kill(); + _logger.LogError("Timed out toggling the system proxy via toggle-proxy.sh."); + return false; + } + if (process.ExitCode != 0) + { + _logger.LogError("toggle-proxy.sh exited with code {ExitCode}.", process.ExitCode); return false; } return true; diff --git a/DevProxy/toggle-proxy.sh b/DevProxy/toggle-proxy.sh index 179ecdb23..50b3f284f 100644 --- a/DevProxy/toggle-proxy.sh +++ b/DevProxy/toggle-proxy.sh @@ -4,18 +4,26 @@ toggle=$1 ip=$2 port=$3 -network_services=$(networksetup -listallnetworkservices | tail -n +2) +# Skip the header line and disabled services (prefixed with *), which networksetup can't configure. +network_services=$(networksetup -listallnetworkservices | tail -n +2 | grep -v '^\*') + +status=0 if [[ "$toggle" == "on" ]]; then while IFS= read -r service; do - networksetup -setsecurewebproxy "$service" $ip $port - networksetup -setwebproxy "$service" $ip $port + [[ -z "$service" ]] && continue + networksetup -setsecurewebproxy "$service" "$ip" "$port" || status=1 + networksetup -setwebproxy "$service" "$ip" "$port" || status=1 done <<<"$network_services" elif [[ "$toggle" == "off" ]]; then while IFS= read -r service; do - networksetup -setsecurewebproxystate "$service" off - networksetup -setwebproxystate "$service" off + [[ -z "$service" ]] && continue + networksetup -setsecurewebproxystate "$service" off || status=1 + networksetup -setwebproxystate "$service" off || status=1 done <<<"$network_services" else echo "Set the first argument to on or off." -fi \ No newline at end of file + status=1 +fi + +exit $status From 9f75e95fd57203be44db8ad5bce53e0ba2dabaae Mon Sep 17 00:00:00 2001 From: Waldek Mastykarz Date: Thu, 5 Nov 2026 12:03:40 +0100 Subject: [PATCH 3/6] Harden system proxy recovery per review feedback - Clear system-proxy ownership on live instances after a forced reset - Report failed orphan restores distinctly and keep records for retry - Continue restoring remaining macOS services after a failure - Fail toggle-proxy.sh when listing network services fails - Parse standalone reset args with System.CommandLine Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- DevProxy/Commands/StopCommand.cs | 25 +++++++++--- DevProxy/Commands/SystemProxyCommand.cs | 31 ++++++++++++++- DevProxy/Program.cs | 16 +++++++- DevProxy/Proxy/SystemProxyManager.cs | 52 +++++++++++++++++-------- DevProxy/toggle-proxy.sh | 6 ++- 5 files changed, 106 insertions(+), 24 deletions(-) diff --git a/DevProxy/Commands/StopCommand.cs b/DevProxy/Commands/StopCommand.cs index 0d8c3636a..08bda9a31 100644 --- a/DevProxy/Commands/StopCommand.cs +++ b/DevProxy/Commands/StopCommand.cs @@ -42,7 +42,7 @@ private async Task RunAsync(ParseResult parseResult, CancellationToken canc // Reconcile any system-proxy registrations left behind by crashed // instances first — this must run before LoadAllStatesAsync, which prunes // (deletes) the stale state files it depends on. - var reconciliation = await SystemProxyManager.ReconcileOrphanedSystemProxiesAsync(cancellationToken); + var reconciliation = await SystemProxyManager.ReconcileOrphanedSystemProxiesAsync(cancellationToken: cancellationToken); var states = await StateManager.LoadAllStatesAsync(cancellationToken); if (states.Count == 0 && reconciliation.OrphanedPids.Count == 0) @@ -61,7 +61,10 @@ private async Task RunAsync(ParseResult parseResult, CancellationToken canc } } - ReportReconciledOrphans(reconciliation); + if (!ReportReconciledOrphans(reconciliation)) + { + exitCode = 1; + } return exitCode; } @@ -84,7 +87,12 @@ private static async Task StopByPidAsync(int pid, bool force, CancellationT var liveOwner = await StateManager.FindSystemProxyInstanceAsync(cancellationToken); if (liveOwner is null) { - new SystemProxyManager(NullLogger.Instance).Disable(); + if (new SystemProxyManager(NullLogger.Instance).Restore() is SystemProxyRestoreResult.Failed) + { + // Keep the state record so a later stop or start retries. + await Console.Error.WriteLineAsync($"Couldn't restore the system proxy left by crashed Dev Proxy (PID: {pid}). Run 'devproxy system-proxy reset' or see https://go.devproxy.net?go=proxy-reset."); + return 1; + } Console.WriteLine($"Restored system proxy left by crashed Dev Proxy (PID: {pid})."); } else @@ -100,14 +108,21 @@ private static async Task StopByPidAsync(int pid, bool force, CancellationT return 1; } - private static void ReportReconciledOrphans(SystemProxyManager.OrphanReconciliation reconciliation) + private static bool ReportReconciledOrphans(SystemProxyManager.OrphanReconciliation reconciliation) { + if (reconciliation.Outcome is SystemProxyManager.OrphanRestoreOutcome.Failed) + { + Console.Error.WriteLine($"Couldn't restore the system proxy left by crashed Dev Proxy (PID: {string.Join(", ", reconciliation.OrphanedPids)}). Run 'devproxy system-proxy reset' or see https://go.devproxy.net?go=proxy-reset."); + return false; + } + foreach (var pid in reconciliation.OrphanedPids) { - Console.WriteLine(reconciliation.SystemProxyRestored + Console.WriteLine(reconciliation.Outcome is SystemProxyManager.OrphanRestoreOutcome.Restored ? $"Restored system proxy left by crashed Dev Proxy (PID: {pid})." : $"Removed stale record for crashed Dev Proxy (PID: {pid}); system proxy is owned by a running instance."); } + return true; } private static async Task StopInstanceAsync(ProxyInstanceState state, bool force, CancellationToken cancellationToken) diff --git a/DevProxy/Commands/SystemProxyCommand.cs b/DevProxy/Commands/SystemProxyCommand.cs index 623ee6a73..ab5bd7c61 100644 --- a/DevProxy/Commands/SystemProxyCommand.cs +++ b/DevProxy/Commands/SystemProxyCommand.cs @@ -45,7 +45,27 @@ private Task ResetAsync(ParseResult parseResult, CancellationToken cancella /// internal static async Task RunResetStandaloneAsync(string[] args) { - var force = args.Any(a => a is "--force" or "-f"); + // Parse with a minimal command tree mirroring the registered command so values + // like '--force false' and invalid arguments are handled the same way. + var forceOption = new Option("--force", "-f"); + var resetCommand = new Command("reset") { forceOption }; + var rootCommand = new RootCommand { new Command("system-proxy") { resetCommand } }; + foreach (var name in new[] { DevProxyCommand.OutputOptionName, DevProxyCommand.LogLevelOptionName }) + { + rootCommand.Options.Add(new Option(name) { Recursive = true }); + } + rootCommand.Options.Add(new Option(DevProxyCommand.NoColorOptionName) { Recursive = true, Arity = ArgumentArity.Zero }); + + var parseResult = rootCommand.Parse(args); + if (parseResult.Errors.Count > 0) + { + foreach (var error in parseResult.Errors) + { + await Console.Error.WriteLineAsync(error.Message); + } + return 1; + } + var force = parseResult.GetValue(forceOption); using var loggerFactory = LoggerFactory.Create(builder => { @@ -89,6 +109,15 @@ private static async Task ResetCoreAsync(bool force, ILogger s.AsSystemProxy)) + { + state.AsSystemProxy = false; + await StateManager.SaveStateAsync(state, cancellationToken); + } + // The settings are restored, so records of dead instances that registered as the // system proxy are stale. Remove them so they don't trigger another restore later. foreach (var orphan in await StateManager.GetOrphanedSystemProxyStatesAsync(cancellationToken)) diff --git a/DevProxy/Program.cs b/DevProxy/Program.cs index f19d03083..1c18fe9b6 100644 --- a/DevProxy/Program.cs +++ b/DevProxy/Program.cs @@ -54,9 +54,23 @@ static async Task ReconcileOrphanedSystemProxiesAsync(bool isJsonOutput) { var reconciliation = await SystemProxyManager.ReconcileOrphanedSystemProxiesAsync(); + if (reconciliation.Outcome is SystemProxyManager.OrphanRestoreOutcome.Failed) + { + var error = $"Couldn't restore the system proxy left by a crashed Dev Proxy instance (PID: {string.Join(", ", reconciliation.OrphanedPids)}). Run 'devproxy system-proxy reset' or see https://go.devproxy.net?go=proxy-reset."; + if (isJsonOutput) + { + await Console.Out.WriteLineAsync(FormatJsonLogEntry("error", error)); + } + else + { + await Console.Error.WriteLineAsync(error); + } + return; + } + foreach (var pid in reconciliation.OrphanedPids) { - var message = reconciliation.SystemProxyRestored + var message = reconciliation.Outcome is SystemProxyManager.OrphanRestoreOutcome.Restored ? $"Recovered system proxy left by a crashed Dev Proxy instance (PID: {pid})." : $"Removed stale system-proxy registration left by a crashed Dev Proxy instance (PID: {pid}) while keeping the current system proxy owner unchanged."; if (isJsonOutput) diff --git a/DevProxy/Proxy/SystemProxyManager.cs b/DevProxy/Proxy/SystemProxyManager.cs index 038452b95..7df9b263c 100644 --- a/DevProxy/Proxy/SystemProxyManager.cs +++ b/DevProxy/Proxy/SystemProxyManager.cs @@ -316,19 +316,22 @@ private static void CaptureMacOS(SystemProxySnapshot snapshot) private bool RestoreMacOS(IEnumerable services) { - try + // Keep going after a failure so one broken service (for example, one that was + // removed while Dev Proxy was running) doesn't leave the others without a proxy. + var succeeded = true; + foreach (var command in services.SelectMany(MacOSNetworkSetup.GetRestoreCommands)) { - foreach (var command in services.SelectMany(MacOSNetworkSetup.GetRestoreCommands)) + try { _ = RunNetworkSetup(command); } - return true; - } - catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or InvalidOperationException or IOException) - { - _logger.LogError(ex, "Failed to restore the system proxy via networksetup."); - return false; + catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or InvalidOperationException or IOException) + { + _logger.LogError(ex, "Failed to restore the system proxy via networksetup {Arguments}.", string.Join(' ', command)); + succeeded = false; + } } + return succeeded; } private static string RunNetworkSetup(params string[] arguments) @@ -428,14 +431,27 @@ internal static ProcessStartInfo CreateToggleScriptStartInfo(string bashScriptPa // Static helpers for orphan reconciliation (crash-cleanup path). // ------------------------------------------------------------------ + /// + /// What happened to the OS proxy settings while reconciling orphaned registrations. + /// + internal enum OrphanRestoreOutcome + { + /// The OS proxy settings were restored. + Restored, + /// A live instance owns the system proxy, so the settings were left alone. + OwnedByLiveInstance, + /// Restoring the settings failed. The recovery records were kept for a retry. + Failed + } + /// /// The outcome of reconciling orphaned system-proxy registrations. /// /// PIDs of dead instances that left a system-proxy registration behind. - /// Whether the OS proxy settings were restored. + /// What happened to the OS proxy settings. internal readonly record struct OrphanReconciliation( IReadOnlyList OrphanedPids, - bool SystemProxyRestored); + OrphanRestoreOutcome Outcome); /// /// Finds the PID of a live process that owns the system proxy: a running detached @@ -461,7 +477,7 @@ internal readonly record struct OrphanReconciliation( /// Restores the OS proxy (unless a live instance still owns it) and removes /// the stale state records. Safe to call when there are no orphans. /// - public static async Task ReconcileOrphanedSystemProxiesAsync(CancellationToken cancellationToken = default) + public static async Task ReconcileOrphanedSystemProxiesAsync(ILogger? logger = null, CancellationToken cancellationToken = default) { var orphanedPids = (await StateManager.GetOrphanedSystemProxyStatesAsync(cancellationToken)) .Select(o => o.Pid) @@ -475,14 +491,18 @@ public static async Task ReconcileOrphanedSystemProxiesAsy if (orphanedPids.Count == 0) { - return new([], false); + return new([], OrphanRestoreOutcome.Restored); } - var restored = false; + var outcome = OrphanRestoreOutcome.OwnedByLiveInstance; if (await FindLiveSystemProxyOwnerAsync(cancellationToken) is null) { - restored = new SystemProxyManager(NullLogger.Instance).Restore() - is SystemProxyRestoreResult.RestoredPrevious or SystemProxyRestoreResult.Disabled; + if (new SystemProxyManager(logger ?? NullLogger.Instance).Restore() is SystemProxyRestoreResult.Failed) + { + // Keep the state records (and the snapshot) so the next start or stop retries. + return new(orphanedPids, OrphanRestoreOutcome.Failed); + } + outcome = OrphanRestoreOutcome.Restored; } foreach (var pid in orphanedPids) @@ -490,6 +510,6 @@ public static async Task ReconcileOrphanedSystemProxiesAsy await StateManager.DeleteStateAsync(pid, cancellationToken); } - return new(orphanedPids, restored); + return new(orphanedPids, outcome); } } diff --git a/DevProxy/toggle-proxy.sh b/DevProxy/toggle-proxy.sh index 50b3f284f..5082cd619 100644 --- a/DevProxy/toggle-proxy.sh +++ b/DevProxy/toggle-proxy.sh @@ -5,7 +5,11 @@ ip=$2 port=$3 # Skip the header line and disabled services (prefixed with *), which networksetup can't configure. -network_services=$(networksetup -listallnetworkservices | tail -n +2 | grep -v '^\*') +if ! all_services=$(networksetup -listallnetworkservices); then + echo "Failed to list network services." + exit 1 +fi +network_services=$(echo "$all_services" | tail -n +2 | grep -v '^\*') status=0 From d14a4894f6797a4981502c4ed492042c3a4db7c5 Mon Sep 17 00:00:00 2001 From: Waldek Mastykarz Date: Thu, 5 Nov 2026 12:11:35 +0100 Subject: [PATCH 4/6] Restore macOS snapshot after partial disable failure; harden standalone reset parsing Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- DevProxy/Commands/DevProxyCommand.cs | 20 +++++++++++++++++--- DevProxy/Commands/SystemProxyCommand.cs | 7 +++---- DevProxy/Proxy/SystemProxyManager.cs | 6 ++++-- 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/DevProxy/Commands/DevProxyCommand.cs b/DevProxy/Commands/DevProxyCommand.cs index af668b8f5..9ef4f5a6d 100644 --- a/DevProxy/Commands/DevProxyCommand.cs +++ b/DevProxy/Commands/DevProxyCommand.cs @@ -143,9 +143,23 @@ public static bool IsSystemProxyResetCommand } var args = Environment.GetCommandLineArgs(); - field = args.Length > 2 && - string.Equals(args[1], "system-proxy", StringComparison.OrdinalIgnoreCase) && - string.Equals(args[2], "reset", StringComparison.OrdinalIgnoreCase) && + // Global options can appear anywhere, so skip them (and their values) to find + // the command path. + var tokens = new List(); + for (var i = 1; i < args.Length; i++) + { + if (args[i] is OutputOptionName or LogLevelOptionName) + { + i++; + } + else if (args[i] is not NoColorOptionName) + { + tokens.Add(args[i]); + } + } + field = tokens.Count > 1 && + string.Equals(tokens[0], "system-proxy", StringComparison.OrdinalIgnoreCase) && + string.Equals(tokens[1], "reset", StringComparison.OrdinalIgnoreCase) && !args.Any(arg => helpOptions.Contains(arg)); _isSystemProxyResetCommandResolved = true; return field; diff --git a/DevProxy/Commands/SystemProxyCommand.cs b/DevProxy/Commands/SystemProxyCommand.cs index ab5bd7c61..ec03c008a 100644 --- a/DevProxy/Commands/SystemProxyCommand.cs +++ b/DevProxy/Commands/SystemProxyCommand.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using DevProxy.Abstractions.Proxy; using DevProxy.Logging; using DevProxy.Proxy; using DevProxy.State; @@ -50,10 +51,8 @@ internal static async Task RunResetStandaloneAsync(string[] args) var forceOption = new Option("--force", "-f"); var resetCommand = new Command("reset") { forceOption }; var rootCommand = new RootCommand { new Command("system-proxy") { resetCommand } }; - foreach (var name in new[] { DevProxyCommand.OutputOptionName, DevProxyCommand.LogLevelOptionName }) - { - rootCommand.Options.Add(new Option(name) { Recursive = true }); - } + rootCommand.Options.Add(new Option(DevProxyCommand.OutputOptionName) { Recursive = true }); + rootCommand.Options.Add(new Option(DevProxyCommand.LogLevelOptionName) { Recursive = true }); rootCommand.Options.Add(new Option(DevProxyCommand.NoColorOptionName) { Recursive = true, Arity = ArgumentArity.Zero }); var parseResult = rootCommand.Parse(args); diff --git a/DevProxy/Proxy/SystemProxyManager.cs b/DevProxy/Proxy/SystemProxyManager.cs index 7df9b263c..972dc52fa 100644 --- a/DevProxy/Proxy/SystemProxyManager.cs +++ b/DevProxy/Proxy/SystemProxyManager.cs @@ -148,9 +148,11 @@ internal SystemProxyRestoreResult Restore() { succeeded = RunToggleScript("off"); restoredPrevious = snapshot?.MacOS is not null; - if (succeeded && restoredPrevious) + // Restore even if turning off failed for some services, so the services that + // did turn off get their previous proxy back. Either failure keeps the snapshot. + if (restoredPrevious && !RestoreMacOS(snapshot!.MacOS!)) { - succeeded = RestoreMacOS(snapshot!.MacOS!); + succeeded = false; } } From 57007bc1b8a29bbf53df17b25abc77adace8b46a Mon Sep 17 00:00:00 2001 From: Waldek Mastykarz Date: Thu, 5 Nov 2026 12:20:27 +0100 Subject: [PATCH 5/6] Detect system-proxy reset via System.CommandLine parsing Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- DevProxy.Tests/SystemProxyManagerTests.cs | 20 ++++++++++++++++ DevProxy/Commands/DevProxyCommand.cs | 18 +-------------- DevProxy/Commands/SystemProxyCommand.cs | 28 +++++++++++++++++++---- 3 files changed, 44 insertions(+), 22 deletions(-) diff --git a/DevProxy.Tests/SystemProxyManagerTests.cs b/DevProxy.Tests/SystemProxyManagerTests.cs index 9ea84fd3e..f08592e0b 100644 --- a/DevProxy.Tests/SystemProxyManagerTests.cs +++ b/DevProxy.Tests/SystemProxyManagerTests.cs @@ -143,3 +143,23 @@ public void Load_CorruptFile_ReturnsNull() } } } + +public class SystemProxyResetDetectionTests +{ + [Theory] + [InlineData("system-proxy", "reset")] + [InlineData("system-proxy", "reset", "--force")] + [InlineData("--output=json", "system-proxy", "reset")] + [InlineData("system-proxy", "--no-color", "reset")] + [InlineData("--log-level", "debug", "system-proxy", "reset", "--bogus")] + public void IsResetInvocation_ReturnsTrue(params string[] args) => + Assert.True(DevProxy.Commands.SystemProxyCommand.IsResetInvocation(args)); + + [Theory] + [InlineData("system-proxy")] + [InlineData("reset")] + [InlineData("stop")] + [InlineData] + public void IsResetInvocation_ReturnsFalse(params string[] args) => + Assert.False(DevProxy.Commands.SystemProxyCommand.IsResetInvocation(args)); +} diff --git a/DevProxy/Commands/DevProxyCommand.cs b/DevProxy/Commands/DevProxyCommand.cs index 9ef4f5a6d..8be9a69a7 100644 --- a/DevProxy/Commands/DevProxyCommand.cs +++ b/DevProxy/Commands/DevProxyCommand.cs @@ -143,23 +143,7 @@ public static bool IsSystemProxyResetCommand } var args = Environment.GetCommandLineArgs(); - // Global options can appear anywhere, so skip them (and their values) to find - // the command path. - var tokens = new List(); - for (var i = 1; i < args.Length; i++) - { - if (args[i] is OutputOptionName or LogLevelOptionName) - { - i++; - } - else if (args[i] is not NoColorOptionName) - { - tokens.Add(args[i]); - } - } - field = tokens.Count > 1 && - string.Equals(tokens[0], "system-proxy", StringComparison.OrdinalIgnoreCase) && - string.Equals(tokens[1], "reset", StringComparison.OrdinalIgnoreCase) && + field = SystemProxyCommand.IsResetInvocation(args.Skip(1).ToArray()) && !args.Any(arg => helpOptions.Contains(arg)); _isSystemProxyResetCommandResolved = true; return field; diff --git a/DevProxy/Commands/SystemProxyCommand.cs b/DevProxy/Commands/SystemProxyCommand.cs index ec03c008a..3c500e68e 100644 --- a/DevProxy/Commands/SystemProxyCommand.cs +++ b/DevProxy/Commands/SystemProxyCommand.cs @@ -41,20 +41,38 @@ private Task ResetAsync(ParseResult parseResult, CancellationToken cancella ResetCoreAsync(parseResult.GetValue(_forceOption), _systemProxyLogger, cancellationToken); /// - /// Runs system-proxy reset without building the application host, so a broken - /// configuration or plugin can't prevent restoring network access. + /// Builds a minimal command tree mirroring the registered system-proxy reset + /// command and the global options, so the standalone path parses arguments the same way. /// - internal static async Task RunResetStandaloneAsync(string[] args) + private static (RootCommand RootCommand, Option ForceOption) CreateStandaloneResetParser() { - // Parse with a minimal command tree mirroring the registered command so values - // like '--force false' and invalid arguments are handled the same way. var forceOption = new Option("--force", "-f"); var resetCommand = new Command("reset") { forceOption }; var rootCommand = new RootCommand { new Command("system-proxy") { resetCommand } }; rootCommand.Options.Add(new Option(DevProxyCommand.OutputOptionName) { Recursive = true }); rootCommand.Options.Add(new Option(DevProxyCommand.LogLevelOptionName) { Recursive = true }); rootCommand.Options.Add(new Option(DevProxyCommand.NoColorOptionName) { Recursive = true, Arity = ArgumentArity.Zero }); + return (rootCommand, forceOption); + } + /// + /// Returns whether the arguments (without the executable) invoke system-proxy reset, + /// wherever global options appear and however their values are attached. + /// + internal static bool IsResetInvocation(string[] args) + { + var commandResult = CreateStandaloneResetParser().RootCommand.Parse(args).CommandResult; + return commandResult.Command.Name == "reset" && + commandResult.Parent is System.CommandLine.Parsing.CommandResult { Command.Name: "system-proxy" }; + } + + /// + /// Runs system-proxy reset without building the application host, so a broken + /// configuration or plugin can't prevent restoring network access. + /// + internal static async Task RunResetStandaloneAsync(string[] args) + { + var (rootCommand, forceOption) = CreateStandaloneResetParser(); var parseResult = rootCommand.Parse(args); if (parseResult.Errors.Count > 0) { From 6e20a0b5b2def857488723ff58f21069a6c5bb53 Mon Sep 17 00:00:00 2001 From: Waldek Mastykarz Date: Thu, 5 Nov 2026 12:29:06 +0100 Subject: [PATCH 6/6] Leave parsed help to the full command tree Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- DevProxy.Tests/SystemProxyManagerTests.cs | 1 + DevProxy/Commands/SystemProxyCommand.cs | 8 +++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/DevProxy.Tests/SystemProxyManagerTests.cs b/DevProxy.Tests/SystemProxyManagerTests.cs index f08592e0b..b1241040a 100644 --- a/DevProxy.Tests/SystemProxyManagerTests.cs +++ b/DevProxy.Tests/SystemProxyManagerTests.cs @@ -159,6 +159,7 @@ public void IsResetInvocation_ReturnsTrue(params string[] args) => [InlineData("system-proxy")] [InlineData("reset")] [InlineData("stop")] + [InlineData("system-proxy", "reset", "--force", "--help")] [InlineData] public void IsResetInvocation_ReturnsFalse(params string[] args) => Assert.False(DevProxy.Commands.SystemProxyCommand.IsResetInvocation(args)); diff --git a/DevProxy/Commands/SystemProxyCommand.cs b/DevProxy/Commands/SystemProxyCommand.cs index 3c500e68e..a0811c95c 100644 --- a/DevProxy/Commands/SystemProxyCommand.cs +++ b/DevProxy/Commands/SystemProxyCommand.cs @@ -61,7 +61,13 @@ private static (RootCommand RootCommand, Option ForceOption) CreateStandal /// internal static bool IsResetInvocation(string[] args) { - var commandResult = CreateStandaloneResetParser().RootCommand.Parse(args).CommandResult; + var parseResult = CreateStandaloneResetParser().RootCommand.Parse(args); + // Leave help (including help in a response file) to the full command tree. + if (parseResult.Action is System.CommandLine.Help.HelpAction) + { + return false; + } + var commandResult = parseResult.CommandResult; return commandResult.Command.Name == "reset" && commandResult.Parent is System.CommandLine.Parsing.CommandResult { Command.Name: "system-proxy" }; }