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:
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -5,6 +5,18 @@ public sealed class ServerConfig
|
||||
public int HttpPort { get; set; } = 8080;
|
||||
public HttpsBinding? HttpsBinding { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// IP addresses Kestrel binds to. Empty = listen on all interfaces (default).
|
||||
/// Non-empty = listen only on the named addresses.
|
||||
/// </summary>
|
||||
public List<string> BindAddresses { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Hostname or IP that the GUI uses when constructing webhook URLs to display.
|
||||
/// Null = "localhost". Has no effect on what Kestrel actually accepts.
|
||||
/// </summary>
|
||||
public string? DisplayHost { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// IPs/CIDRs allowed to set X-Forwarded-For. Empty = forwarded headers are ignored
|
||||
/// and the direct connection IP is always used.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -104,6 +104,7 @@ internal sealed class AdminPipeServer : BackgroundService
|
||||
Running = true,
|
||||
HttpPort = snap.HttpPort,
|
||||
HttpsPort = snap.HttpsBinding?.Port,
|
||||
DisplayHost = snap.DisplayHost,
|
||||
StartedAt = _state.StartedAt,
|
||||
EndpointCount = snap.Endpoints.Count,
|
||||
});
|
||||
|
||||
@@ -36,7 +36,7 @@ try
|
||||
|
||||
builder.WebHost.ConfigureKestrel(opts =>
|
||||
{
|
||||
opts.ListenAnyIP(initialConfig.HttpPort);
|
||||
ConfigureHttp(opts, initialConfig);
|
||||
ConfigureHttps(opts, initialConfig.HttpsBinding);
|
||||
});
|
||||
|
||||
@@ -87,6 +87,24 @@ finally
|
||||
Log.CloseAndFlush();
|
||||
}
|
||||
|
||||
static void ConfigureHttp(Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerOptions opts, ServerConfig cfg)
|
||||
{
|
||||
if (cfg.BindAddresses is { Count: > 0 } binds)
|
||||
{
|
||||
foreach (var entry in binds)
|
||||
{
|
||||
if (System.Net.IPAddress.TryParse(entry, out var ip))
|
||||
opts.Listen(ip, cfg.HttpPort);
|
||||
else
|
||||
Log.Warning("Skipping invalid bind address {Entry}", entry);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
opts.ListenAnyIP(cfg.HttpPort);
|
||||
}
|
||||
}
|
||||
|
||||
static void ConfigureHttps(Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerOptions opts, HttpsBinding? binding)
|
||||
{
|
||||
if (binding is null || binding.Kind == HttpsBindingKind.None) return;
|
||||
|
||||
@@ -102,6 +102,7 @@ public sealed class ServiceState
|
||||
private static bool HasListenerSettingsChanged(ServerConfig oldCfg, ServerConfig newCfg)
|
||||
{
|
||||
if (oldCfg.HttpPort != newCfg.HttpPort) return true;
|
||||
if (!oldCfg.BindAddresses.SequenceEqual(newCfg.BindAddresses, StringComparer.OrdinalIgnoreCase)) return true;
|
||||
var a = oldCfg.HttpsBinding;
|
||||
var b = newCfg.HttpsBinding;
|
||||
if ((a is null) != (b is null)) return true;
|
||||
@@ -110,6 +111,7 @@ public sealed class ServiceState
|
||||
if (a.Kind != b.Kind || a.Port != b.Port || a.PfxPath != b.PfxPath || a.Thumbprint != b.Thumbprint)
|
||||
return true;
|
||||
}
|
||||
// DisplayHost is cosmetic; don't restart for it.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user