diff --git a/DevProxy.Abstractions/Proxy/ISystemProxyManager.cs b/DevProxy.Abstractions/Proxy/ISystemProxyManager.cs
index d29269a3..f31fde62 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 659998d1..b1241040 100644
--- a/DevProxy.Tests/SystemProxyManagerTests.cs
+++ b/DevProxy.Tests/SystemProxyManagerTests.cs
@@ -24,3 +24,143 @@ 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);
+ }
+ }
+}
+
+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("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/DevProxyCommand.cs b/DevProxy/Commands/DevProxyCommand.cs
index 6577c0bc..8be9a69a 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,23 @@ public static bool IsConfigValidateCommand
}
}
+ public static bool IsSystemProxyResetCommand
+ {
+ get
+ {
+ if (_isSystemProxyResetCommandResolved)
+ {
+ return field;
+ }
+
+ var args = Environment.GetCommandLineArgs();
+ field = SystemProxyCommand.IsResetInvocation(args.Skip(1).ToArray()) &&
+ !args.Any(arg => helpOptions.Contains(arg));
+ _isSystemProxyResetCommandResolved = true;
+ return field;
+ }
+ }
+
public static bool IsDetachedMode
{
get
@@ -636,6 +654,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 f2ae0303..08bda9a3 100644
--- a/DevProxy/Commands/StopCommand.cs
+++ b/DevProxy/Commands/StopCommand.cs
@@ -42,10 +42,10 @@ 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.Orphans.Count == 0)
+ if (states.Count == 0 && reconciliation.OrphanedPids.Count == 0)
{
Console.WriteLine("Dev Proxy is not running.");
return 1;
@@ -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)
{
- foreach (var orphan in reconciliation.Orphans)
+ 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.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.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
new file mode 100644
index 00000000..a0811c95
--- /dev/null
+++ b/DevProxy/Commands/SystemProxyCommand.cs
@@ -0,0 +1,156 @@
+// 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.Abstractions.Proxy;
+using DevProxy.Logging;
+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 Task ResetAsync(ParseResult parseResult, CancellationToken cancellationToken) =>
+ ResetCoreAsync(parseResult.GetValue(_forceOption), _systemProxyLogger, cancellationToken);
+
+ ///
+ /// 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.
+ ///
+ private static (RootCommand RootCommand, Option ForceOption) CreateStandaloneResetParser()
+ {
+ 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 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" };
+ }
+
+ ///
+ /// 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)
+ {
+ 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 =>
+ {
+ _ = 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)
+ {
+ 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(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.");
+ return 1;
+ }
+
+ // A forced reset took the system proxy away from live detached instances. Record
+ // that, so stopping them or recovering after they crash doesn't change the
+ // restored settings again.
+ foreach (var state in (await StateManager.LoadAllStatesAsync(cancellationToken)).Where(s => 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))
+ {
+ await StateManager.DeleteStateAsync(orphan.Pid, cancellationToken);
+ }
+
+ 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 ec32588f..1c18fe9b 100644
--- a/DevProxy/Program.cs
+++ b/DevProxy/Program.cs
@@ -51,20 +51,28 @@
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)
+ if (reconciliation.Outcome is SystemProxyManager.OrphanRestoreOutcome.Failed)
{
- 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 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.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)
{
await Console.Out.WriteLineAsync(FormatJsonLogEntry("info", message));
@@ -74,6 +82,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 +450,25 @@ 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.
+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 3571ac42..972dc52f 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,50 +14,91 @@
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;
+ private bool _savedSnapshot;
+
+ 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);
@@ -65,14 +107,101 @@ public void Enable(string? ipAddress, int port)
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
+ /// 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
{
- RunToggleScript("off");
+ succeeded = RunToggleScript("off");
+ restoredPrevious = snapshot?.MacOS is not null;
+ // 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 = false;
+ }
}
+
+ if (!succeeded)
+ {
+ // Keep the snapshot so a later reset can still put the previous settings back.
+ return SystemProxyRestoreResult.Failed;
+ }
+
+ if (snapshot is not null)
+ {
+ 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);
+ _savedSnapshot = true;
+ }
+ 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 +212,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 +222,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.");
+ 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 clear the system proxy via the Windows registry.");
+ _logger.LogError(ex, "Failed to restore the system proxy via the Windows registry.");
+ return false;
}
}
@@ -134,13 +304,86 @@ 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"));
+ snapshot.MacOS = [.. services.Select(service => new MacOSServiceProxySettings
+ {
+ Service = service,
+ Web = MacOSNetworkSetup.ParseProxyEndpoint(RunNetworkSetup("-getwebproxy", service)),
+ SecureWeb = MacOSNetworkSetup.ParseProxyEndpoint(RunNetworkSetup("-getsecurewebproxy", service)),
+ })];
+ }
+
+ private bool RestoreMacOS(IEnumerable services)
+ {
+ // 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))
+ {
+ try
+ {
+ _ = RunNetworkSetup(command);
+ }
+ 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)
+ {
+ 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();
+ 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();
+ }
+
+ 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 +395,20 @@ private void 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;
}
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;
}
}
@@ -181,39 +433,85 @@ 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.
+ /// What happened to the OS proxy settings.
internal readonly record struct OrphanReconciliation(
- IReadOnlyList Orphans,
- bool SystemProxyDisabled);
+ IReadOnlyList OrphanedPids,
+ OrphanRestoreOutcome Outcome);
///
- /// 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)
+ public static async Task ReconcileOrphanedSystemProxiesAsync(ILogger? logger = null, 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);
+ return new([], OrphanRestoreOutcome.Restored);
}
- var liveOwner = await StateManager.FindSystemProxyInstanceAsync(cancellationToken);
- var disabled = false;
- if (liveOwner is null)
+ var outcome = OrphanRestoreOutcome.OwnedByLiveInstance;
+ if (await FindLiveSystemProxyOwnerAsync(cancellationToken) is null)
{
- new SystemProxyManager(NullLogger.Instance).Disable();
- disabled = true;
+ 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 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, outcome);
}
}
diff --git a/DevProxy/Proxy/SystemProxySnapshot.cs b/DevProxy/Proxy/SystemProxySnapshot.cs
new file mode 100644
index 00000000..acb3b0cc
--- /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 5ca80d53..fa24e16d 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
{
diff --git a/DevProxy/toggle-proxy.sh b/DevProxy/toggle-proxy.sh
index 179ecdb2..5082cd61 100644
--- a/DevProxy/toggle-proxy.sh
+++ b/DevProxy/toggle-proxy.sh
@@ -4,18 +4,30 @@ 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.
+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
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