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 @@
+
@@ -26,6 +27,41 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -70,5 +106,6 @@
+
diff --git a/src/WebhookServer.Service/AdminPipeServer.cs b/src/WebhookServer.Service/AdminPipeServer.cs
index 2e7d25b..54fff23 100644
--- a/src/WebhookServer.Service/AdminPipeServer.cs
+++ b/src/WebhookServer.Service/AdminPipeServer.cs
@@ -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,
});
diff --git a/src/WebhookServer.Service/Program.cs b/src/WebhookServer.Service/Program.cs
index 4f28c3d..8eefe98 100644
--- a/src/WebhookServer.Service/Program.cs
+++ b/src/WebhookServer.Service/Program.cs
@@ -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;
diff --git a/src/WebhookServer.Service/ServiceState.cs b/src/WebhookServer.Service/ServiceState.cs
index 75b0145..13474de 100644
--- a/src/WebhookServer.Service/ServiceState.cs
+++ b/src/WebhookServer.Service/ServiceState.cs
@@ -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;
}
}