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)