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
@@ -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;
}