Skip to content

Commit cfd5dfa

Browse files
BornToBeRootclaude
andauthored
Feature: Network configuration for Network Configuration Operators (#3606) (#3608)
- 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 <noreply@anthropic.com>
1 parent 1ce6a42 commit cfd5dfa

7 files changed

Lines changed: 133 additions & 33 deletions

File tree

‎Source/NETworkManager.Localization/Resources/Strings.Designer.cs‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Source/NETworkManager.Localization/Resources/Strings.resx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -316,7 +316,7 @@
316316
<value>Configuration applied</value>
317317
</data>
318318
<data name="NetworkInterfaceConfigureAdminMessage" xml:space="preserve">
319-
<value>Configuring the network interface requires elevated rights!</value>
319+
<value>Configuring the network interface requires elevated rights (administrator or member of the "Network Configuration Operators" group)!</value>
320320
</data>
321321
<data name="NewTab" xml:space="preserve">
322322
<value>New tab</value>

‎Source/NETworkManager.Models/Network/NetworkInterface.cs‎

Lines changed: 106 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using System.Net;
55
using System.Net.NetworkInformation;
66
using System.Net.Sockets;
7+
using System.Security.Principal;
78
using System.Threading.Tasks;
89
using log4net;
910
using Microsoft.Win32;
@@ -172,12 +173,7 @@ public static List<NetworkInterfaceInfo> GetNetworkInterfaces()
172173
}
173174
}
174175

175-
// Check if autoconfiguration for DNS is enabled (only possible via registry key)
176-
var nameServerKey =
177-
Registry.LocalMachine.OpenSubKey(
178-
$@"SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\Interfaces\{networkInterface.Id}");
179-
var dnsAutoconfigurationEnabled = nameServerKey?.GetValue("NameServer") != null &&
180-
string.IsNullOrEmpty(nameServerKey.GetValue("NameServer")?.ToString());
176+
var dnsAutoconfigurationEnabled = IsDNSAutoconfigurationEnabled(networkInterface.Id) == true;
181177

182178
// Check if IPv4 protocol is available
183179
var ipv4ProtocolAvailable = true;
@@ -420,10 +416,13 @@ private static void ConfigureNetworkInterface(NetworkInterfaceConfig config)
420416
var name = PowerShellHelper.EscapeSingleQuotes(config.Name);
421417
var commands = new List<string>();
422418

419+
// netsh returns an error if DHCP is already enabled. Skip these commands, so every error can be reported.
420+
var (dhcpEnabled, dnsAutoconfigurationEnabled) = GetCurrentDhcpState(config.Name);
421+
423422
// IP
424423
if (config.EnableStaticIPAddress)
425424
commands.Add($"& netsh interface ipv4 set address name='{name}' source=static address={config.IPAddress} mask={config.Subnetmask} gateway={config.Gateway}");
426-
else
425+
else if (dhcpEnabled != true)
427426
commands.Add($"& netsh interface ipv4 set address name='{name}' source=dhcp");
428427

429428
// DNS
@@ -434,14 +433,75 @@ private static void ConfigureNetworkInterface(NetworkInterfaceConfig config)
434433
if (!string.IsNullOrEmpty(config.SecondaryDNSServer))
435434
commands.Add($"& netsh interface ipv4 add dnsservers name='{name}' address={config.SecondaryDNSServer} index=2 validate=no");
436435
}
437-
else
436+
else if (dnsAutoconfigurationEnabled != true)
438437
{
439438
commands.Add($"& netsh interface ipv4 set dnsservers name='{name}' source=dhcp");
440439
}
441440

442441
RunCommands(commands);
443442
}
444443

444+
/// <summary>
445+
/// Checks whether the DNS servers of a network interface are assigned automatically (DHCP). This can only be
446+
/// detected via the registry: the "NameServer" value of the interface is empty, if no static DNS servers are set.
447+
/// </summary>
448+
/// <param name="interfaceId">ID (GUID) of the network interface.</param>
449+
/// <returns>Whether DNS autoconfiguration is enabled, or <see langword="null" /> if it cannot be determined.</returns>
450+
private static bool? IsDNSAutoconfigurationEnabled(string interfaceId)
451+
{
452+
using var interfaceKey = Registry.LocalMachine.OpenSubKey(
453+
$@"SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\Interfaces\{interfaceId}");
454+
455+
var nameServer = interfaceKey?.GetValue("NameServer");
456+
457+
return nameServer == null ? null : string.IsNullOrEmpty(nameServer.ToString());
458+
}
459+
460+
/// <summary>
461+
/// Reads the current DHCP state of a network interface right before it is configured (the information shown in the
462+
/// UI can be outdated). Returns <see langword="null" /> for values that cannot be determined, in which case the
463+
/// corresponding command is not skipped.
464+
/// </summary>
465+
/// <param name="name">Name of the network interface.</param>
466+
/// <returns>Whether DHCP is enabled for the IPv4 address and for the DNS servers.</returns>
467+
private static (bool? DhcpEnabled, bool? DNSAutoconfigurationEnabled) GetCurrentDhcpState(string name)
468+
{
469+
try
470+
{
471+
var networkInterface = System.Net.NetworkInformation.NetworkInterface.GetAllNetworkInterfaces()
472+
.FirstOrDefault(x => x.Name == name);
473+
474+
if (networkInterface == null)
475+
return (null, null);
476+
477+
return (networkInterface.GetIPProperties().GetIPv4Properties()?.IsDhcpEnabled,
478+
IsDNSAutoconfigurationEnabled(networkInterface.Id));
479+
}
480+
catch (Exception ex)
481+
{
482+
Log.Warn($"Could not read the DHCP state of network interface \"{name}\".", ex);
483+
return (null, null);
484+
}
485+
}
486+
487+
/// <summary>
488+
/// Checks whether the current process is allowed to change the network configuration (IP addresses, DNS servers,
489+
/// release/renew, flush DNS cache). This is the case if it is running as administrator or as member of the
490+
/// "Network Configuration Operators" group, which may modify the TCP/IP settings without being an administrator.
491+
/// </summary>
492+
/// <remarks><see cref="WindowsPrincipal.IsInRole(SecurityIdentifier)" /> only returns <see langword="true" /> if the
493+
/// group is enabled in the access token. With UAC, both groups are only enabled if the application is running
494+
/// elevated, so a non-elevated process is correctly reported as not allowed.</remarks>
495+
/// <returns><see langword="true" /> if the network configuration can be changed.</returns>
496+
public static bool HasPermissionToConfigure()
497+
{
498+
var principal = new WindowsPrincipal(WindowsIdentity.GetCurrent());
499+
500+
return principal.IsInRole(WindowsBuiltInRole.Administrator) ||
501+
principal.IsInRole(new SecurityIdentifier(WellKnownSidType.BuiltinNetworkConfigurationOperatorsSid,
502+
null));
503+
}
504+
445505
/// <summary>
446506
/// Asynchronously flushes the system DNS resolver cache.
447507
/// </summary>
@@ -459,7 +519,7 @@ public static Task FlushDnsAsync()
459519
/// <remarks>Requires the application to be running with administrator privileges.</remarks>
460520
private static void FlushDns()
461521
{
462-
RunCommands(["& ipconfig /flushdns"], checkExitCode: true);
522+
RunCommands(["& ipconfig /flushdns"]);
463523
}
464524

465525
/// <summary>
@@ -496,7 +556,7 @@ private static void ReleaseRenew(IPConfigReleaseRenewMode mode, string adapterNa
496556
if (mode is IPConfigReleaseRenewMode.ReleaseRenew6 or IPConfigReleaseRenewMode.Renew6)
497557
commands.Add($"& ipconfig /renew6 '{name}'");
498558

499-
RunCommands(commands, checkExitCode: true);
559+
RunCommands(commands);
500560
}
501561

502562
/// <summary>
@@ -551,28 +611,47 @@ private static void RemoveIPAddressFromNetworkInterface(NetworkInterfaceConfig c
551611
}
552612

553613
/// <summary>
554-
/// Runs one or more native commands via a PowerShell runspace.
555-
/// When <paramref name="checkExitCode"/> is <see langword="true"/>, a non-zero
556-
/// <c>$LASTEXITCODE</c> is treated as an error via <c>Write-Error</c>.
557-
/// Use <see langword="false"/> for <c>netsh</c>, whose exit codes are unreliable
558-
/// for idempotent operations (e.g. returns 1 when already on DHCP).
614+
/// Runs one or more native commands (<c>netsh</c>, <c>ipconfig</c>) one after another via a PowerShell runspace.
615+
/// Stops at the first command that fails (non-zero exit code) and throws an exception with the output of the
616+
/// command (e.g. "The requested operation requires elevation."), which is localized by Windows and shown to the
617+
/// user. Commands that would not change anything (and therefore fail with <c>netsh</c>) must not be passed.
559618
/// </summary>
560-
private static void RunCommands(IEnumerable<string> commands, bool checkExitCode = false)
619+
/// <param name="commands">Commands to run, e.g. <c>&amp; netsh interface ipv4 ...</c>.</param>
620+
/// <exception cref="Exception">If a command fails.</exception>
621+
private static void RunCommands(IEnumerable<string> commands)
561622
{
562-
string script;
623+
foreach (var command in commands)
624+
{
625+
using var ps = SMA.PowerShell.Create();
626+
627+
ps.AddScript($"$output = {command} 2>&1 | Out-String{Environment.NewLine}" +
628+
"[PSCustomObject]@{ Output = $output; ExitCode = $LASTEXITCODE }");
629+
630+
var result = ps.Invoke().LastOrDefault();
563631

564-
if (checkExitCode)
565-
script = string.Join(Environment.NewLine, commands.Select(cmd =>
566-
$"{cmd}{Environment.NewLine}if ($LASTEXITCODE -ne 0) {{ Write-Error \"Command failed with exit code $LASTEXITCODE\" }}"));
567-
else
568-
script = string.Join(Environment.NewLine, commands);
632+
// The script itself failed and did not return a result.
633+
if (result == null)
634+
throw new Exception(ps.HadErrors
635+
? string.Join("; ", ps.Streams.Error.Select(e => e.ToString()))
636+
: $"\"{command}\" did not return a result.");
569637

570-
using var ps = SMA.PowerShell.Create();
571-
ps.AddScript(script);
572-
ps.Invoke();
638+
// Each command runs in a new runspace, so the exit code is only set if the native command was started.
639+
var exitCode = result.Properties["ExitCode"]?.Value as int?;
640+
641+
if (exitCode == 0)
642+
continue;
573643

574-
if (checkExitCode && ps.HadErrors)
575-
throw new Exception(string.Join("; ", ps.Streams.Error.Select(e => e.ToString())));
644+
var output = result.Properties["Output"]?.Value?.ToString()?.Trim();
645+
646+
Log.Error($"Command \"{command}\" failed with exit code {exitCode?.ToString() ?? "-/-"}: {output}");
647+
648+
if (!string.IsNullOrEmpty(output))
649+
throw new Exception(output);
650+
651+
throw new Exception(exitCode == null
652+
? $"\"{command.TrimStart('&', ' ')}\" could not be run."
653+
: $"\"{command.TrimStart('&', ' ')}\" failed with exit code {exitCode}.");
654+
}
576655
}
577656

578657
#endregion

‎Source/NETworkManager/ViewModels/NetworkInterfaceViewModel.cs‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,12 @@ public bool IsNetworkInterfaceLoading
6060
}
6161
}
6262

63+
/// <summary>
64+
/// Gets a value indicating whether the application has the permission to change the network configuration
65+
/// (running as administrator or as member of the "Network Configuration Operators" group).
66+
/// </summary>
67+
public bool HasPermissionToConfigure { get; } = NetworkInterface.HasPermissionToConfigure();
68+
6369
/// <summary>
6470
/// Gets or sets a value indicating whether configuration is allowed.
6571
/// </summary>
@@ -846,7 +852,7 @@ private bool AdditionalCommands_CanExecute(object parameter)
846852
!ConfigurationManager.Current.IsChildWindowOpen;
847853
}
848854

849-
private bool ConfigureCommands_CanExecute(object parameter) => ConfigurationManager.Current.IsAdmin &&
855+
private bool ConfigureCommands_CanExecute(object parameter) => HasPermissionToConfigure &&
850856
Application.Current.MainWindow != null &&
851857
!((MetroWindow)Application.Current.MainWindow).IsAnyDialogOpen &&
852858
!ConfigurationManager.Current.IsChildWindowOpen;

‎Source/NETworkManager/Views/NetworkInterfaceView.xaml‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,10 @@
6363
</DataTemplate>
6464
<x:Array x:Key="NetworkInterfaceProfileContextMenuExtraItems" Type="{x:Type MenuItem}">
6565
<MenuItem Header="{x:Static localization:Strings.Apply}"
66-
Command="{Binding ApplyProfileCommand}">
66+
Command="{Binding ApplyProfileCommand}"
67+
ToolTip="{x:Static localization:Strings.NetworkInterfaceConfigureAdminMessage}"
68+
ToolTipService.ShowOnDisabled="True"
69+
ToolTipService.IsEnabled="{Binding HasPermissionToConfigure, Converter={StaticResource BooleanReverseConverter}}">
6770
<MenuItem.Icon>
6871
<Rectangle Width="16" Height="16"
6972
Fill="{DynamicResource MahApps.Brushes.Gray3}">
@@ -1464,7 +1467,7 @@
14641467
</Grid>
14651468
<Grid Grid.Row="5"
14661469
Background="{DynamicResource MahApps.Brushes.Gray10}"
1467-
Visibility="{Binding Source={x:Static settings:ConfigurationManager.Current}, Path=IsAdmin, Converter={StaticResource BooleanReverseToVisibilityCollapsedConverter}}"
1470+
Visibility="{Binding HasPermissionToConfigure, Converter={StaticResource BooleanReverseToVisibilityCollapsedConverter}}"
14681471
Margin="0,10,0,0">
14691472
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Stretch"
14701473
Margin="10">

‎Website/docs/application/network-interface.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,11 @@ The options you can set correspond to the network adapter properties `Internetpr
7979

8080
:::note
8181

82-
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.
82+
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.
83+
84+
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.
85+
86+
If a change fails (e.g. due to missing permissions), the error message of Windows is shown below the configuration.
8387

8488
:::
8589

‎Website/docs/changelog/next-release.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ Release date: **xx.xx.2026**
3636

3737
## Improvements
3838

39+
**Network Interface**
40+
41+
- 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)
42+
3943
- 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)
4044

4145
**IP Scanner**
@@ -67,6 +71,10 @@ Release date: **xx.xx.2026**
6771

6872
## Bug Fixes
6973

74+
**Network Interface**
75+
76+
- 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)
77+
7078
**Dashboard**
7179

7280
- 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)

0 commit comments

Comments
 (0)