From cde28cdaa387d123053e9c1068c1ead7319e40be Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:44:10 +0200 Subject: [PATCH] Feature: Network configuration for Network Configuration Operators (#3606) - Network Interface: members of the "Network Configuration Operators" group may change the network configuration without being an administrator, if NETworkManager runs elevated (IsInRole only reports enabled groups, so the check is only true in an elevated process). Only used by Network Interface; IsAdmin is unchanged for all other tools. - Errors of netsh/ipconfig are no longer ignored: each command's output and exit code are checked, the first failing command stops the operation and its (localized) message is shown instead of "applied successfully". Commands that would not change anything (DHCP/DNS autoconfiguration already enabled) are skipped, since netsh fails for them. - The admin hint is also shown as tooltip on the disabled "Apply" in the profile context menu, and mentions the group. Co-Authored-By: Claude Opus 5.5 --- .../Resources/Strings.Designer.cs | 2 +- .../Resources/Strings.resx | 2 +- .../Network/NetworkInterface.cs | 133 ++++++++++++++---- .../ViewModels/NetworkInterfaceViewModel.cs | 8 +- .../Views/NetworkInterfaceView.xaml | 7 +- Website/docs/application/network-interface.md | 6 +- Website/docs/changelog/next-release.md | 8 ++ 7 files changed, 133 insertions(+), 33 deletions(-) diff --git a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs index ccfb7aaabe..2b6f10c2e1 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs +++ b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs @@ -7238,7 +7238,7 @@ public static string NetworkInterfaceConfigurationAppliedSuccessfully { } /// - /// Looks up a localized string similar to Configuring the network interface requires elevated rights!. + /// Looks up a localized string similar to Configuring the network interface requires elevated rights (administrator or member of the "Network Configuration Operators" group)!. /// public static string NetworkInterfaceConfigureAdminMessage { get { diff --git a/Source/NETworkManager.Localization/Resources/Strings.resx b/Source/NETworkManager.Localization/Resources/Strings.resx index bdf839e1c5..df3e0efaf2 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.resx +++ b/Source/NETworkManager.Localization/Resources/Strings.resx @@ -316,7 +316,7 @@ Configuration applied - Configuring the network interface requires elevated rights! + Configuring the network interface requires elevated rights (administrator or member of the "Network Configuration Operators" group)! New tab diff --git a/Source/NETworkManager.Models/Network/NetworkInterface.cs b/Source/NETworkManager.Models/Network/NetworkInterface.cs index ff8905bb83..8481df1fa4 100644 --- a/Source/NETworkManager.Models/Network/NetworkInterface.cs +++ b/Source/NETworkManager.Models/Network/NetworkInterface.cs @@ -4,6 +4,7 @@ using System.Net; using System.Net.NetworkInformation; using System.Net.Sockets; +using System.Security.Principal; using System.Threading.Tasks; using log4net; using Microsoft.Win32; @@ -172,12 +173,7 @@ public static List GetNetworkInterfaces() } } - // Check if autoconfiguration for DNS is enabled (only possible via registry key) - var nameServerKey = - Registry.LocalMachine.OpenSubKey( - $@"SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\Interfaces\{networkInterface.Id}"); - var dnsAutoconfigurationEnabled = nameServerKey?.GetValue("NameServer") != null && - string.IsNullOrEmpty(nameServerKey.GetValue("NameServer")?.ToString()); + var dnsAutoconfigurationEnabled = IsDNSAutoconfigurationEnabled(networkInterface.Id) == true; // Check if IPv4 protocol is available var ipv4ProtocolAvailable = true; @@ -420,10 +416,13 @@ private static void ConfigureNetworkInterface(NetworkInterfaceConfig config) var name = PowerShellHelper.EscapeSingleQuotes(config.Name); var commands = new List(); + // netsh returns an error if DHCP is already enabled. Skip these commands, so every error can be reported. + var (dhcpEnabled, dnsAutoconfigurationEnabled) = GetCurrentDhcpState(config.Name); + // IP if (config.EnableStaticIPAddress) commands.Add($"& netsh interface ipv4 set address name='{name}' source=static address={config.IPAddress} mask={config.Subnetmask} gateway={config.Gateway}"); - else + else if (dhcpEnabled != true) commands.Add($"& netsh interface ipv4 set address name='{name}' source=dhcp"); // DNS @@ -434,7 +433,7 @@ private static void ConfigureNetworkInterface(NetworkInterfaceConfig config) if (!string.IsNullOrEmpty(config.SecondaryDNSServer)) commands.Add($"& netsh interface ipv4 add dnsservers name='{name}' address={config.SecondaryDNSServer} index=2 validate=no"); } - else + else if (dnsAutoconfigurationEnabled != true) { commands.Add($"& netsh interface ipv4 set dnsservers name='{name}' source=dhcp"); } @@ -442,6 +441,67 @@ private static void ConfigureNetworkInterface(NetworkInterfaceConfig config) RunCommands(commands); } + /// + /// Checks whether the DNS servers of a network interface are assigned automatically (DHCP). This can only be + /// detected via the registry: the "NameServer" value of the interface is empty, if no static DNS servers are set. + /// + /// ID (GUID) of the network interface. + /// Whether DNS autoconfiguration is enabled, or if it cannot be determined. + private static bool? IsDNSAutoconfigurationEnabled(string interfaceId) + { + using var interfaceKey = Registry.LocalMachine.OpenSubKey( + $@"SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\Interfaces\{interfaceId}"); + + var nameServer = interfaceKey?.GetValue("NameServer"); + + return nameServer == null ? null : string.IsNullOrEmpty(nameServer.ToString()); + } + + /// + /// Reads the current DHCP state of a network interface right before it is configured (the information shown in the + /// UI can be outdated). Returns for values that cannot be determined, in which case the + /// corresponding command is not skipped. + /// + /// Name of the network interface. + /// Whether DHCP is enabled for the IPv4 address and for the DNS servers. + private static (bool? DhcpEnabled, bool? DNSAutoconfigurationEnabled) GetCurrentDhcpState(string name) + { + try + { + var networkInterface = System.Net.NetworkInformation.NetworkInterface.GetAllNetworkInterfaces() + .FirstOrDefault(x => x.Name == name); + + if (networkInterface == null) + return (null, null); + + return (networkInterface.GetIPProperties().GetIPv4Properties()?.IsDhcpEnabled, + IsDNSAutoconfigurationEnabled(networkInterface.Id)); + } + catch (Exception ex) + { + Log.Warn($"Could not read the DHCP state of network interface \"{name}\".", ex); + return (null, null); + } + } + + /// + /// Checks whether the current process is allowed to change the network configuration (IP addresses, DNS servers, + /// release/renew, flush DNS cache). This is the case if it is running as administrator or as member of the + /// "Network Configuration Operators" group, which may modify the TCP/IP settings without being an administrator. + /// + /// only returns if the + /// group is enabled in the access token. With UAC, both groups are only enabled if the application is running + /// elevated, so a non-elevated process is correctly reported as not allowed. + /// if the network configuration can be changed. + public static bool HasPermissionToConfigure() + { + var principal = new WindowsPrincipal(WindowsIdentity.GetCurrent()); + + return principal.IsInRole(WindowsBuiltInRole.Administrator) || + principal.IsInRole(new SecurityIdentifier(WellKnownSidType.BuiltinNetworkConfigurationOperatorsSid, + null)); + } + /// /// Asynchronously flushes the system DNS resolver cache. /// @@ -459,7 +519,7 @@ public static Task FlushDnsAsync() /// Requires the application to be running with administrator privileges. private static void FlushDns() { - RunCommands(["& ipconfig /flushdns"], checkExitCode: true); + RunCommands(["& ipconfig /flushdns"]); } /// @@ -496,7 +556,7 @@ private static void ReleaseRenew(IPConfigReleaseRenewMode mode, string adapterNa if (mode is IPConfigReleaseRenewMode.ReleaseRenew6 or IPConfigReleaseRenewMode.Renew6) commands.Add($"& ipconfig /renew6 '{name}'"); - RunCommands(commands, checkExitCode: true); + RunCommands(commands); } /// @@ -551,28 +611,47 @@ private static void RemoveIPAddressFromNetworkInterface(NetworkInterfaceConfig c } /// - /// Runs one or more native commands via a PowerShell runspace. - /// When is , a non-zero - /// $LASTEXITCODE is treated as an error via Write-Error. - /// Use for netsh, whose exit codes are unreliable - /// for idempotent operations (e.g. returns 1 when already on DHCP). + /// Runs one or more native commands (netsh, ipconfig) one after another via a PowerShell runspace. + /// Stops at the first command that fails (non-zero exit code) and throws an exception with the output of the + /// command (e.g. "The requested operation requires elevation."), which is localized by Windows and shown to the + /// user. Commands that would not change anything (and therefore fail with netsh) must not be passed. /// - private static void RunCommands(IEnumerable commands, bool checkExitCode = false) + /// Commands to run, e.g. & netsh interface ipv4 .... + /// If a command fails. + private static void RunCommands(IEnumerable commands) { - string script; + foreach (var command in commands) + { + using var ps = SMA.PowerShell.Create(); + + ps.AddScript($"$output = {command} 2>&1 | Out-String{Environment.NewLine}" + + "[PSCustomObject]@{ Output = $output; ExitCode = $LASTEXITCODE }"); + + var result = ps.Invoke().LastOrDefault(); - if (checkExitCode) - script = string.Join(Environment.NewLine, commands.Select(cmd => - $"{cmd}{Environment.NewLine}if ($LASTEXITCODE -ne 0) {{ Write-Error \"Command failed with exit code $LASTEXITCODE\" }}")); - else - script = string.Join(Environment.NewLine, commands); + // The script itself failed and did not return a result. + if (result == null) + throw new Exception(ps.HadErrors + ? string.Join("; ", ps.Streams.Error.Select(e => e.ToString())) + : $"\"{command}\" did not return a result."); - using var ps = SMA.PowerShell.Create(); - ps.AddScript(script); - ps.Invoke(); + // Each command runs in a new runspace, so the exit code is only set if the native command was started. + var exitCode = result.Properties["ExitCode"]?.Value as int?; + + if (exitCode == 0) + continue; - if (checkExitCode && ps.HadErrors) - throw new Exception(string.Join("; ", ps.Streams.Error.Select(e => e.ToString()))); + var output = result.Properties["Output"]?.Value?.ToString()?.Trim(); + + Log.Error($"Command \"{command}\" failed with exit code {exitCode?.ToString() ?? "-/-"}: {output}"); + + if (!string.IsNullOrEmpty(output)) + throw new Exception(output); + + throw new Exception(exitCode == null + ? $"\"{command.TrimStart('&', ' ')}\" could not be run." + : $"\"{command.TrimStart('&', ' ')}\" failed with exit code {exitCode}."); + } } #endregion diff --git a/Source/NETworkManager/ViewModels/NetworkInterfaceViewModel.cs b/Source/NETworkManager/ViewModels/NetworkInterfaceViewModel.cs index c3e1f1532c..dc48aa71b0 100644 --- a/Source/NETworkManager/ViewModels/NetworkInterfaceViewModel.cs +++ b/Source/NETworkManager/ViewModels/NetworkInterfaceViewModel.cs @@ -60,6 +60,12 @@ public bool IsNetworkInterfaceLoading } } + /// + /// Gets a value indicating whether the application has the permission to change the network configuration + /// (running as administrator or as member of the "Network Configuration Operators" group). + /// + public bool HasPermissionToConfigure { get; } = NetworkInterface.HasPermissionToConfigure(); + /// /// Gets or sets a value indicating whether configuration is allowed. /// @@ -846,7 +852,7 @@ private bool AdditionalCommands_CanExecute(object parameter) !ConfigurationManager.Current.IsChildWindowOpen; } - private bool ConfigureCommands_CanExecute(object parameter) => ConfigurationManager.Current.IsAdmin && + private bool ConfigureCommands_CanExecute(object parameter) => HasPermissionToConfigure && Application.Current.MainWindow != null && !((MetroWindow)Application.Current.MainWindow).IsAnyDialogOpen && !ConfigurationManager.Current.IsChildWindowOpen; diff --git a/Source/NETworkManager/Views/NetworkInterfaceView.xaml b/Source/NETworkManager/Views/NetworkInterfaceView.xaml index aa1da2d67a..ab6069335a 100644 --- a/Source/NETworkManager/Views/NetworkInterfaceView.xaml +++ b/Source/NETworkManager/Views/NetworkInterfaceView.xaml @@ -63,7 +63,10 @@ + Command="{Binding ApplyProfileCommand}" + ToolTip="{x:Static localization:Strings.NetworkInterfaceConfigureAdminMessage}" + ToolTipService.ShowOnDisabled="True" + ToolTipService.IsEnabled="{Binding HasPermissionToConfigure, Converter={StaticResource BooleanReverseConverter}}"> @@ -1464,7 +1467,7 @@ diff --git a/Website/docs/application/network-interface.md b/Website/docs/application/network-interface.md index d1ed9e4b5d..7c112f196b 100644 --- a/Website/docs/application/network-interface.md +++ b/Website/docs/application/network-interface.md @@ -79,7 +79,11 @@ The options you can set correspond to the network adapter properties `Internetpr :::note -All actions on this tab require the application to be running with **administrator privileges**. If the app is not elevated, a banner is shown at the bottom with a **Restart as Administrator** button. +All actions on this tab (and **Apply** from the profile context menu) require the application to be running elevated, either with **administrator privileges** or as a member of the **Network Configuration Operators** group. If the app is not elevated, a banner is shown at the bottom with a **Restart as Administrator** button. + +Members of the **Network Configuration Operators** group don't need to be administrators: with User Account Control, the group is only active in an elevated process. Use **Restart as Administrator** and confirm the prompt with your own credentials. + +If a change fails (e.g. due to missing permissions), the error message of Windows is shown below the configuration. ::: diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index ff7e9e00da..e787d48cfc 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -36,6 +36,10 @@ Release date: **xx.xx.2026** ## Improvements +**Network Interface** + +- Members of the **Network Configuration Operators** group can now change the network configuration (IP address, DNS servers, release/renew) without being an administrator. NETworkManager has to run elevated (**Restart as Administrator**, confirm with your own credentials). The hint is now also shown as tooltip on **Apply** in the profile context menu. [#3606](https://github.com/BornToBeRoot/NETworkManager/issues/3606) + - The collapsed/expanded state of profile groups (e.g. **linux-server**) is now remembered per profile file and shared across all tools, instead of resetting every time you switch tools or restart the application. [#3539](https://github.com/BornToBeRoot/NETworkManager/pull/3539) **IP Scanner** @@ -67,6 +71,10 @@ Release date: **xx.xx.2026** ## Bug Fixes +**Network Interface** + +- Fixed errors when changing the network configuration (e.g. missing permissions) not being shown — the change was reported as successful although it failed. The error message of Windows is now shown. [#3606](https://github.com/BornToBeRoot/NETworkManager/issues/3606) + **Dashboard** - Fixed the **Network Connection** widget running a full connection check on every application startup, even if the Status Window or Dashboard was never opened. It now only checks when actually shown - via the tray icon, on a network change, or when the Dashboard tab is opened. [#3553](https://github.com/BornToBeRoot/NETworkManager/pull/3553)