From 28479272d59df481f93a452c64c3de76b1356276 Mon Sep 17 00:00:00 2001 From: Justin Paul Date: Fri, 8 May 2026 09:27:18 -0400 Subject: [PATCH] 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) --- src/WebhookServer.Core/Ipc/AdminProtocol.cs | 1 + src/WebhookServer.Core/Models/ServerConfig.cs | 12 +++ src/WebhookServer.Gui/App.xaml | 1 + .../Converters/Converters.cs | 9 +++ .../ViewModels/MainViewModel.cs | 7 +- .../ViewModels/ServerSettingsViewModel.cs | 74 +++++++++++++++++++ .../Views/ServerSettings.xaml | 39 +++++++++- src/WebhookServer.Service/AdminPipeServer.cs | 1 + src/WebhookServer.Service/Program.cs | 20 ++++- src/WebhookServer.Service/ServiceState.cs | 2 + 10 files changed, 162 insertions(+), 4 deletions(-) diff --git a/src/WebhookServer.Core/Ipc/AdminProtocol.cs b/src/WebhookServer.Core/Ipc/AdminProtocol.cs index 1e0397d..2c9b37f 100644 --- a/src/WebhookServer.Core/Ipc/AdminProtocol.cs +++ b/src/WebhookServer.Core/Ipc/AdminProtocol.cs @@ -52,6 +52,7 @@ public sealed class StatusInfo public bool Running { get; set; } public int HttpPort { get; set; } public int? HttpsPort { get; set; } + public string? DisplayHost { get; set; } public DateTimeOffset StartedAt { get; set; } public int EndpointCount { get; set; } } diff --git a/src/WebhookServer.Core/Models/ServerConfig.cs b/src/WebhookServer.Core/Models/ServerConfig.cs index 52620fb..2bef02b 100644 --- a/src/WebhookServer.Core/Models/ServerConfig.cs +++ b/src/WebhookServer.Core/Models/ServerConfig.cs @@ -5,6 +5,18 @@ public sealed class ServerConfig public int HttpPort { get; set; } = 8080; public HttpsBinding? HttpsBinding { get; set; } + /// + /// IP addresses Kestrel binds to. Empty = listen on all interfaces (default). + /// Non-empty = listen only on the named addresses. + /// + public List BindAddresses { get; set; } = new(); + + /// + /// Hostname or IP that the GUI uses when constructing webhook URLs to display. + /// Null = "localhost". Has no effect on what Kestrel actually accepts. + /// + public string? DisplayHost { get; set; } + /// /// IPs/CIDRs allowed to set X-Forwarded-For. Empty = forwarded headers are ignored /// and the direct connection IP is always used. diff --git a/src/WebhookServer.Gui/App.xaml b/src/WebhookServer.Gui/App.xaml index 6b883d4..e2bc87a 100644 --- a/src/WebhookServer.Gui/App.xaml +++ b/src/WebhookServer.Gui/App.xaml @@ -8,5 +8,6 @@ + diff --git a/src/WebhookServer.Gui/Converters/Converters.cs b/src/WebhookServer.Gui/Converters/Converters.cs index a7b32c2..e09d260 100644 --- a/src/WebhookServer.Gui/Converters/Converters.cs +++ b/src/WebhookServer.Gui/Converters/Converters.cs @@ -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) diff --git a/src/WebhookServer.Gui/ViewModels/MainViewModel.cs b/src/WebhookServer.Gui/ViewModels/MainViewModel.cs index 29e76b9..fe5eda9 100644 --- a/src/WebhookServer.Gui/ViewModels/MainViewModel.cs +++ b/src/WebhookServer.Gui/ViewModels/MainViewModel.cs @@ -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); } diff --git a/src/WebhookServer.Gui/ViewModels/ServerSettingsViewModel.cs b/src/WebhookServer.Gui/ViewModels/ServerSettingsViewModel.cs index 592baad..4b855ad 100644 --- a/src/WebhookServer.Gui/ViewModels/ServerSettingsViewModel.cs +++ b/src/WebhookServer.Gui/ViewModels/ServerSettingsViewModel.cs @@ -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"; + + /// One row per detected local IPv4/IPv6 address. Bound for "listen on" checkboxes. + public ObservableCollection Addresses { get; } = new(); + + /// Suggestions for the Display URL host dropdown (detected IPs + localhost + machine name). + public ObservableCollection 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(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 TrustedProxiesList => (TrustedProxiesText ?? "").Split(new[] { '\r', '\n', ',' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(); + public List BindAddressesList => + ListenAllInterfaces + ? new List() + : 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(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; } diff --git a/src/WebhookServer.Gui/Views/ServerSettings.xaml b/src/WebhookServer.Gui/Views/ServerSettings.xaml index 7bd40b3..75533dc 100644 --- a/src/WebhookServer.Gui/Views/ServerSettings.xaml +++ b/src/WebhookServer.Gui/Views/ServerSettings.xaml @@ -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}"> @@ -14,6 +14,7 @@