Configurable bind addresses + display host in Server Settings

ServerConfig grows two fields:
- BindAddresses: list of IPs Kestrel binds to (empty = all interfaces,
  current behavior). Listening only on a subset is useful when the host
  has multiple NICs and the webhook should not be reachable on all of
  them.
- DisplayHost: the hostname/IP the GUI splices into the URL column and
  Copy URL button. Cosmetic; doesn't affect what the server accepts.

Server Settings dialog gains a "Network" section: a checkbox for "all
interfaces" plus per-NIC checkboxes auto-detected via NetworkInterface.
GetAllNetworkInterfaces, and an editable ComboBox for the display host
pre-populated with detected IPs and the machine name.

Listener restart fires on BindAddresses change but not on DisplayHost
change (cosmetic).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-08 09:27:18 -04:00
parent 4ef8d20578
commit 28479272d5
10 changed files with 162 additions and 4 deletions
+1
View File
@@ -8,5 +8,6 @@
<conv:BoolToBrushConverter x:Key="ConnFill"/>
<conv:StringEqualsConverter x:Key="StringEqualsConverter"/>
<conv:HookUrlConverter x:Key="HookUrl"/>
<conv:InvertBoolConverter x:Key="InvertBool"/>
</Application.Resources>
</Application>
@@ -28,6 +28,15 @@ public sealed class HookUrlConverter : IMultiValueConverter
=> throw new NotSupportedException();
}
public sealed class InvertBoolConverter : IValueConverter
{
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
=> value is bool b && !b;
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> value is bool b && !b;
}
public sealed class StringEqualsConverter : IValueConverter
{
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
@@ -57,8 +57,9 @@ public sealed partial class MainViewModel : ObservableObject
if (status is not null)
{
HttpBaseUrl = $"http://localhost:{status.HttpPort}";
HttpsBaseUrl = status.HttpsPort.HasValue ? $"https://localhost:{status.HttpsPort.Value}" : null;
var host = string.IsNullOrEmpty(status.DisplayHost) ? "localhost" : status.DisplayHost;
HttpBaseUrl = $"http://{host}:{status.HttpPort}";
HttpsBaseUrl = status.HttpsPort.HasValue ? $"https://{host}:{status.HttpsPort.Value}" : null;
}
Endpoints.Clear();
@@ -202,6 +203,8 @@ public sealed partial class MainViewModel : ObservableObject
ServerConfig.HttpPort = vm.HttpPort;
ServerConfig.TrustedProxies = vm.TrustedProxiesList;
ServerConfig.HttpsBinding = vm.BuildBinding();
ServerConfig.BindAddresses = vm.BindAddressesList;
ServerConfig.DisplayHost = vm.DisplayHostValue;
await _client.InvokeAsync(AdminOps.UpdateConfig, ServerConfig).ConfigureAwait(false);
await RefreshAsync().ConfigureAwait(false);
}
@@ -1,3 +1,7 @@
using System.Collections.ObjectModel;
using System.Net;
using System.Net.NetworkInformation;
using System.Net.Sockets;
using System.Runtime.Versioning;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
@@ -16,6 +20,14 @@ public sealed partial class ServerSettingsViewModel : ObservableObject
[ObservableProperty] private string _pfxPassword = "";
[ObservableProperty] private string _thumbprint = "";
[ObservableProperty] private string _trustedProxiesText = "";
[ObservableProperty] private bool _listenAllInterfaces = true;
[ObservableProperty] private string _displayHost = "localhost";
/// <summary>One row per detected local IPv4/IPv6 address. Bound for "listen on" checkboxes.</summary>
public ObservableCollection<NetworkAddressRow> Addresses { get; } = new();
/// <summary>Suggestions for the Display URL host dropdown (detected IPs + localhost + machine name).</summary>
public ObservableCollection<string> DisplayHostChoices { get; } = new();
public bool Accepted { get; private set; }
@@ -31,11 +43,48 @@ public sealed partial class ServerSettingsViewModel : ObservableObject
PfxPath = b?.PfxPath ?? "";
PfxPassword = b?.PfxPassword?.Plaintext ?? "";
Thumbprint = b?.Thumbprint ?? "";
var detected = DetectLocalAddresses();
var alreadyBound = new HashSet<string>(config.BindAddresses, StringComparer.OrdinalIgnoreCase);
ListenAllInterfaces = config.BindAddresses.Count == 0;
foreach (var (addr, label) in detected)
{
Addresses.Add(new NetworkAddressRow
{
Address = addr,
Label = label,
IsBound = !ListenAllInterfaces && alreadyBound.Contains(addr),
});
}
// Surface any persisted address that isn't currently detected (e.g. a NIC unplugged
// since save) so the user can keep or remove it explicitly.
foreach (var entry in config.BindAddresses)
{
if (Addresses.Any(a => string.Equals(a.Address, entry, StringComparison.OrdinalIgnoreCase))) continue;
Addresses.Add(new NetworkAddressRow { Address = entry, Label = "(not currently present)", IsBound = true });
}
DisplayHostChoices.Add("localhost");
DisplayHostChoices.Add(Environment.MachineName);
foreach (var (addr, _) in detected)
if (!DisplayHostChoices.Contains(addr))
DisplayHostChoices.Add(addr);
DisplayHost = string.IsNullOrEmpty(config.DisplayHost) ? "localhost" : config.DisplayHost;
}
public List<string> TrustedProxiesList =>
(TrustedProxiesText ?? "").Split(new[] { '\r', '\n', ',' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
public List<string> BindAddressesList =>
ListenAllInterfaces
? new List<string>()
: Addresses.Where(a => a.IsBound).Select(a => a.Address).ToList();
public string? DisplayHostValue =>
string.IsNullOrEmpty(DisplayHost) || DisplayHost == "localhost" ? null : DisplayHost.Trim();
public HttpsBinding? BuildBinding()
{
if (!HttpsEnabled) return null;
@@ -58,4 +107,29 @@ public sealed partial class ServerSettingsViewModel : ObservableObject
[RelayCommand]
private void Save() => Accepted = true;
private static IEnumerable<(string Address, string Label)> DetectLocalAddresses()
{
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var ni in NetworkInterface.GetAllNetworkInterfaces())
{
if (ni.OperationalStatus != OperationalStatus.Up) continue;
if (ni.NetworkInterfaceType == NetworkInterfaceType.Tunnel) continue;
foreach (var ua in ni.GetIPProperties().UnicastAddresses)
{
if (ua.Address.AddressFamily != AddressFamily.InterNetwork &&
ua.Address.AddressFamily != AddressFamily.InterNetworkV6) continue;
var key = ua.Address.ToString();
if (!seen.Add(key)) continue;
yield return (key, $"{ni.Name} ({ni.NetworkInterfaceType})");
}
}
}
}
public sealed partial class NetworkAddressRow : ObservableObject
{
public required string Address { get; init; }
public required string Label { get; init; }
[ObservableProperty] private bool _isBound;
}
@@ -5,7 +5,7 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="clr-namespace:WebhookServer.Gui.ViewModels"
mc:Ignorable="d"
Title="Server Settings" Height="500" Width="540"
Title="Server Settings" Height="720" Width="600"
WindowStartupLocation="CenterOwner"
d:DataContext="{d:DesignInstance Type=vm:ServerSettingsViewModel}">
<DockPanel Margin="12">
@@ -14,6 +14,7 @@
<Button Content="Cancel" Width="80" IsCancel="True"/>
</StackPanel>
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel>
<GroupBox Header="HTTP" Padding="6">
<Grid>
@@ -26,6 +27,41 @@
</Grid>
</GroupBox>
<GroupBox Header="Network" Padding="6" Margin="0,8,0,0">
<StackPanel>
<CheckBox Content="Listen on all interfaces (0.0.0.0 + ::)" IsChecked="{Binding ListenAllInterfaces}"/>
<TextBlock Foreground="Gray" FontStyle="Italic" FontSize="11" Margin="20,2,0,0"
Text="Uncheck to bind only to the addresses you select below."/>
<ItemsControl ItemsSource="{Binding Addresses}" Margin="20,4,0,0"
IsEnabled="{Binding ListenAllInterfaces, Converter={StaticResource InvertBool}}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<CheckBox IsChecked="{Binding IsBound}" Margin="0,1,0,1">
<StackPanel Orientation="Horizontal">
<TextBlock Text="{Binding Address}" FontFamily="Consolas" Width="220"/>
<TextBlock Text="{Binding Label}" Foreground="Gray" Margin="8,0,0,0"/>
</StackPanel>
</CheckBox>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Grid Margin="0,8,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="160"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBlock Text="Display URL host" VerticalAlignment="Center"/>
<ComboBox Grid.Column="1" IsEditable="True"
ItemsSource="{Binding DisplayHostChoices}"
Text="{Binding DisplayHost, UpdateSourceTrigger=PropertyChanged}"
FontFamily="Consolas"/>
</Grid>
<TextBlock Foreground="Gray" FontStyle="Italic" FontSize="11" Margin="160,2,0,0"
Text="Used in the URL column and Copy URL button. Cosmetic only - doesn't affect what the server actually accepts."/>
</StackPanel>
</GroupBox>
<GroupBox Header="HTTPS" Padding="6" Margin="0,8,0,0">
<StackPanel>
<CheckBox Content="Enabled" IsChecked="{Binding HttpsEnabled}"/>
@@ -70,5 +106,6 @@
<TextBox Text="{Binding TrustedProxiesText, UpdateSourceTrigger=LostFocus}" AcceptsReturn="True" MinHeight="80" TextWrapping="Wrap" VerticalScrollBarVisibility="Auto"/>
</GroupBox>
</StackPanel>
</ScrollViewer>
</DockPanel>
</Window>