GUI UX, secret visibility, browser-friendly hooks, deploy script

GUI:
- URL column in endpoint grid + Copy URL toolbar button so the full
  http://host:port/hook/<slug> is one click away
- Double-click a row to open the edit dialog
- Bearer/HMAC sections in the editor hide when the auth mode doesn't
  use them, and reappear with previously-entered values when switched
  back
- Log panel auto-scroll checkbox (default on) plus 3s polling so log
  entries stream in without manual refresh
- Secret fields are now plain text with a Copy button. Anyone who can
  open the admin-pipe-ACL'd GUI is already SYSTEM-equivalent on the
  host, so masking the value just made recovery harder. PFX password
  in Server Settings gets the same treatment.

Service:
- Admin pipe ops log info-level lines on every mutation
  (create/update/delete/enable/disable/update-config/bind-https) so
  GUI activity is visible in the Serilog file
- /hook/{slug} accepts GET as well as POST so a browser smoke-test
  works without curl
- /favicon.ico returns 204 so browser hits don't pollute logs with 404s
- AdminPipeServer no longer strips plaintext secrets when sending
  config to the GUI; the pipe ACL already restricts to SYSTEM/Admins

Scripts:
- New deploy.ps1: stops + republishes + copies binaries to
  C:\Program Files\WebhookServer + (re)installs the Windows Service
- install-service.ps1 now uses sc.exe argv splatting consistently for
  both create and config paths

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-08 08:47:11 -04:00
parent 882d5332b4
commit 87bcb6807f
15 changed files with 299 additions and 62 deletions
+1
View File
@@ -7,5 +7,6 @@
<conv:NullToBoolConverter x:Key="NotNull"/>
<conv:BoolToBrushConverter x:Key="ConnFill"/>
<conv:StringEqualsConverter x:Key="StringEqualsConverter"/>
<conv:HookUrlConverter x:Key="HookUrl"/>
</Application.Resources>
</Application>
@@ -13,6 +13,21 @@ public sealed class NullToBoolConverter : IValueConverter
=> throw new NotSupportedException();
}
public sealed class HookUrlConverter : IMultiValueConverter
{
public object Convert(object[] values, Type targetType, object? parameter, CultureInfo culture)
{
if (values.Length < 2) return "";
var slug = values[0] as string ?? "";
var baseUrl = values[1] as string ?? "";
if (string.IsNullOrEmpty(baseUrl) || string.IsNullOrEmpty(slug)) return "";
return $"{baseUrl.TrimEnd('/')}/hook/{slug}";
}
public object[] ConvertBack(object? value, Type[] targetTypes, object? parameter, CultureInfo culture)
=> throw new NotSupportedException();
}
public sealed class StringEqualsConverter : IValueConverter
{
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+35 -4
View File
@@ -28,6 +28,10 @@
<Button Content="Delete" Command="{Binding DeleteEndpointCommand}"
IsEnabled="{Binding SelectedEndpoint, Converter={StaticResource NotNull}}"/>
<Separator/>
<Button Content="Copy URL" Command="{Binding CopyEndpointUrlCommand}"
IsEnabled="{Binding SelectedEndpoint, Converter={StaticResource NotNull}}"
ToolTip="Copy the full webhook URL for the selected endpoint to the clipboard"/>
<Separator/>
<Button Content="Server Settings…" Command="{Binding EditServerSettingsCommand}"/>
</ToolBar>
@@ -46,6 +50,11 @@
CanUserDeleteRows="False"
IsReadOnly="True"
HeadersVisibility="Column">
<DataGrid.RowStyle>
<Style TargetType="DataGridRow">
<EventSetter Event="MouseDoubleClick" Handler="OnRowDoubleClick"/>
</Style>
</DataGrid.RowStyle>
<DataGrid.Columns>
<DataGridTemplateColumn Header="Enabled" Width="80">
<DataGridTemplateColumn.CellTemplate>
@@ -56,7 +65,25 @@
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTextColumn Header="Slug" Binding="{Binding Slug}" Width="*"/>
<DataGridTextColumn Header="Slug" Binding="{Binding Slug}" Width="120"/>
<DataGridTemplateColumn Header="URL" Width="*">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate DataType="{x:Type models:EndpointConfig}">
<TextBox IsReadOnly="True"
BorderThickness="0"
Background="Transparent"
Padding="0"
VerticalAlignment="Center">
<TextBox.Text>
<MultiBinding Converter="{StaticResource HookUrl}" Mode="OneWay">
<Binding Path="Slug"/>
<Binding Path="DataContext.HttpBaseUrl" RelativeSource="{RelativeSource AncestorType=Window}"/>
</MultiBinding>
</TextBox.Text>
</TextBox>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTextColumn Header="Auth" Binding="{Binding AuthMode}" Width="80"/>
<DataGridTextColumn Header="Executor" Binding="{Binding ExecutorType}" Width="140"/>
<DataGridTextColumn Header="Mode" Binding="{Binding ResponseMode}" Width="80"/>
@@ -71,16 +98,20 @@
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Text="Recent log entries" FontWeight="Bold" Margin="6,4"/>
<Button Grid.Column="1" Content="Refresh" Command="{Binding RefreshLogTailCommand}" Margin="6,2"/>
<CheckBox Grid.Column="1" Content="Auto-scroll" IsChecked="{Binding AutoScrollLogs}" VerticalAlignment="Center" Margin="6,2"/>
<Button Grid.Column="2" Content="Refresh" Command="{Binding RefreshLogTailCommand}" Margin="6,2"/>
</Grid>
<TextBox Text="{Binding LogTail, Mode=OneWay}"
<TextBox x:Name="LogTailBox"
Text="{Binding LogTail, Mode=OneWay}"
IsReadOnly="True"
FontFamily="Consolas"
VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Auto"
TextWrapping="NoWrap"/>
TextWrapping="NoWrap"
TextChanged="OnLogTailChanged"/>
</DockPanel>
</Grid>
</DockPanel>
+14
View File
@@ -1,4 +1,6 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using WebhookServer.Gui.Services;
using WebhookServer.Gui.ViewModels;
@@ -13,4 +15,16 @@ public partial class MainWindow : Window
DataContext = vm;
Loaded += async (_, _) => await vm.RefreshCommand.ExecuteAsync(null);
}
private void OnLogTailChanged(object sender, TextChangedEventArgs e)
{
if (DataContext is MainViewModel vm && vm.AutoScrollLogs && sender is TextBox box)
box.ScrollToEnd();
}
private void OnRowDoubleClick(object sender, MouseButtonEventArgs e)
{
if (DataContext is MainViewModel vm && vm.EditEndpointCommand.CanExecute(null))
vm.EditEndpointCommand.Execute(null);
}
}
@@ -1,5 +1,6 @@
using System.Runtime.Versioning;
using System.Text.Json;
using System.Windows;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using WebhookServer.Core.Models;
@@ -29,6 +30,29 @@ public sealed partial class EndpointEditorViewModel : ObservableObject
public Array ExecutorTypes { get; } = Enum.GetValues(typeof(ExecutorType));
public Array ResponseModes { get; } = Enum.GetValues(typeof(ResponseMode));
/// <summary>
/// Proxy for <see cref="EndpointConfig.AuthMode"/> that emits change notifications
/// for the visibility flags so the bearer/HMAC sections show/hide reactively.
/// </summary>
public AuthMode SelectedAuthMode
{
get => Endpoint.AuthMode;
set
{
if (Endpoint.AuthMode == value) return;
Endpoint.AuthMode = value;
OnPropertyChanged();
OnPropertyChanged(nameof(BearerVisible));
OnPropertyChanged(nameof(HmacVisible));
}
}
public Visibility BearerVisible =>
Endpoint.AuthMode == AuthMode.Bearer ? Visibility.Visible : Visibility.Collapsed;
public Visibility HmacVisible =>
Endpoint.AuthMode == AuthMode.Hmac ? Visibility.Visible : Visibility.Collapsed;
public string AllowedClientsText
{
get => string.Join(Environment.NewLine, Endpoint.AllowedClients);
@@ -49,9 +73,9 @@ public sealed partial class EndpointEditorViewModel : ObservableObject
}
}
public string BearerSecretInput
public string BearerSecret
{
get => "";
get => Endpoint.Bearer?.Secret.Plaintext ?? "";
set
{
Endpoint.Bearer ??= new BearerOptions();
@@ -60,9 +84,9 @@ public sealed partial class EndpointEditorViewModel : ObservableObject
}
}
public string HmacSecretInput
public string HmacSecret
{
get => "";
get => Endpoint.Hmac?.Secret.Plaintext ?? "";
set
{
Endpoint.Hmac ??= new HmacOptions();
@@ -4,6 +4,7 @@ using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using WebhookServer.Core.Ipc;
@@ -24,11 +25,19 @@ public sealed partial class MainViewModel : ObservableObject
[ObservableProperty] private string _connectionStatus = "Disconnected";
[ObservableProperty] private bool _isConnected;
[ObservableProperty] private string _logTail = "";
[ObservableProperty] private bool _autoScrollLogs = true;
[ObservableProperty] private ServerConfig _serverConfig = new();
[ObservableProperty] private string _httpBaseUrl = "http://localhost:8080";
[ObservableProperty] private string? _httpsBaseUrl;
private readonly DispatcherTimer _logTimer;
public MainViewModel(AdminPipeClient client)
{
_client = client;
_logTimer = new DispatcherTimer(DispatcherPriority.Background) { Interval = TimeSpan.FromSeconds(3) };
_logTimer.Tick += async (_, _) => await RefreshLogTailAsync();
_logTimer.Start();
}
[RelayCommand]
@@ -46,6 +55,12 @@ public sealed partial class MainViewModel : ObservableObject
? $"Connected — HTTP {status!.HttpPort}{(status.HttpsPort.HasValue ? $" / HTTPS {status.HttpsPort}" : "")}"
: "Disconnected";
if (status is not null)
{
HttpBaseUrl = $"http://localhost:{status.HttpPort}";
HttpsBaseUrl = status.HttpsPort.HasValue ? $"https://localhost:{status.HttpsPort.Value}" : null;
}
Endpoints.Clear();
if (config is not null)
{
@@ -159,6 +174,21 @@ public sealed partial class MainViewModel : ObservableObject
}
}
[RelayCommand]
private void CopyEndpointUrl()
{
if (SelectedEndpoint is null || string.IsNullOrEmpty(HttpBaseUrl)) return;
var url = $"{HttpBaseUrl.TrimEnd('/')}/hook/{SelectedEndpoint.Slug}";
try
{
Clipboard.SetText(url);
}
catch (Exception ex)
{
ShowError("Copy failed", ex);
}
}
[RelayCommand]
private async Task EditServerSettingsAsync()
{
@@ -13,7 +13,7 @@ public sealed partial class ServerSettingsViewModel : ObservableObject
[ObservableProperty] private bool _httpsEnabled;
[ObservableProperty] private string _httpsMode = "PfxFile";
[ObservableProperty] private string _pfxPath = "";
[ObservableProperty] private string _pfxPasswordInput = "";
[ObservableProperty] private string _pfxPassword = "";
[ObservableProperty] private string _thumbprint = "";
[ObservableProperty] private string _trustedProxiesText = "";
@@ -29,6 +29,7 @@ public sealed partial class ServerSettingsViewModel : ObservableObject
HttpsPort = b?.Port ?? 8443;
HttpsMode = b?.Kind == HttpsBindingKind.CertStoreThumbprint ? "Thumbprint" : "PfxFile";
PfxPath = b?.PfxPath ?? "";
PfxPassword = b?.PfxPassword?.Plaintext ?? "";
Thumbprint = b?.Thumbprint ?? "";
}
@@ -49,8 +50,8 @@ public sealed partial class ServerSettingsViewModel : ObservableObject
{
binding.Kind = HttpsBindingKind.PfxFile;
binding.PfxPath = PfxPath;
if (!string.IsNullOrEmpty(PfxPasswordInput))
binding.PfxPassword = ProtectedString.FromPlaintext(PfxPasswordInput);
if (!string.IsNullOrEmpty(PfxPassword))
binding.PfxPassword = ProtectedString.FromPlaintext(PfxPassword);
}
return binding;
}
+25 -19
View File
@@ -47,32 +47,38 @@
</Grid.ColumnDefinitions>
<TextBlock Text="Mode" VerticalAlignment="Center"/>
<ComboBox Grid.Column="1" ItemsSource="{Binding AuthModes}"
SelectedItem="{Binding Endpoint.AuthMode, Mode=TwoWay}"/>
SelectedItem="{Binding SelectedAuthMode, Mode=TwoWay}"/>
</Grid>
<Grid Margin="0,4,0,0">
<Grid Margin="0,4,0,0" Visibility="{Binding BearerVisible}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="120"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Text="Bearer secret" VerticalAlignment="Center"/>
<PasswordBox Grid.Column="1" PasswordChanged="OnBearerPasswordChanged"/>
</Grid>
<Grid Margin="0,4,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="120"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBlock Text="HMAC secret" VerticalAlignment="Center"/>
<PasswordBox Grid.Column="1" PasswordChanged="OnHmacPasswordChanged"/>
</Grid>
<Grid Margin="0,4,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="120"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBlock Text="HMAC header" VerticalAlignment="Center"/>
<TextBox Grid.Column="1" Text="{Binding Endpoint.Hmac.HeaderName, UpdateSourceTrigger=PropertyChanged}"/>
<TextBox Grid.Column="1" Text="{Binding BearerSecret, UpdateSourceTrigger=PropertyChanged}" FontFamily="Consolas"/>
<Button Grid.Column="2" Content="Copy" Margin="4,0,0,0" Padding="6,0" Click="OnCopyBearer"/>
</Grid>
<StackPanel Visibility="{Binding HmacVisible}">
<Grid Margin="0,4,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="120"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Text="HMAC secret" VerticalAlignment="Center"/>
<TextBox Grid.Column="1" Text="{Binding HmacSecret, UpdateSourceTrigger=PropertyChanged}" FontFamily="Consolas"/>
<Button Grid.Column="2" Content="Copy" Margin="4,0,0,0" Padding="6,0" Click="OnCopyHmac"/>
</Grid>
<Grid Margin="0,4,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="120"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBlock Text="HMAC header" VerticalAlignment="Center"/>
<TextBox Grid.Column="1" Text="{Binding Endpoint.Hmac.HeaderName, UpdateSourceTrigger=PropertyChanged}"/>
</Grid>
</StackPanel>
</StackPanel>
</GroupBox>
@@ -19,15 +19,15 @@ public partial class EndpointEditor : Window
Close();
}
private void OnBearerPasswordChanged(object sender, RoutedEventArgs e)
private void OnCopyBearer(object sender, RoutedEventArgs e)
{
if (DataContext is EndpointEditorViewModel vm && sender is PasswordBox box)
vm.BearerSecretInput = box.Password;
if (DataContext is EndpointEditorViewModel vm && !string.IsNullOrEmpty(vm.BearerSecret))
try { Clipboard.SetText(vm.BearerSecret); } catch { /* clipboard busy — silent */ }
}
private void OnHmacPasswordChanged(object sender, RoutedEventArgs e)
private void OnCopyHmac(object sender, RoutedEventArgs e)
{
if (DataContext is EndpointEditorViewModel vm && sender is PasswordBox box)
vm.HmacSecretInput = box.Password;
if (DataContext is EndpointEditorViewModel vm && !string.IsNullOrEmpty(vm.HmacSecret))
try { Clipboard.SetText(vm.HmacSecret); } catch { /* clipboard busy — silent */ }
}
}
@@ -58,7 +58,7 @@
<TextBox Grid.Row="2" Grid.Column="1" Text="{Binding PfxPath, UpdateSourceTrigger=PropertyChanged}" Margin="0,4,0,0"/>
<TextBlock Grid.Row="3" Text="PFX password" VerticalAlignment="Center" Margin="0,4,0,0"/>
<PasswordBox Grid.Row="3" Grid.Column="1" PasswordChanged="OnPfxPasswordChanged" Margin="0,4,0,0"/>
<TextBox Grid.Row="3" Grid.Column="1" Text="{Binding PfxPassword, UpdateSourceTrigger=PropertyChanged}" Margin="0,4,0,0" FontFamily="Consolas"/>
<TextBlock Grid.Row="4" Text="Thumbprint" VerticalAlignment="Center" Margin="0,4,0,0"/>
<TextBox Grid.Row="4" Grid.Column="1" Text="{Binding Thumbprint, UpdateSourceTrigger=PropertyChanged}" Margin="0,4,0,0"/>
@@ -19,12 +19,6 @@ public partial class ServerSettings : Window
Close();
}
private void OnPfxPasswordChanged(object sender, RoutedEventArgs e)
{
if (DataContext is ServerSettingsViewModel vm && sender is PasswordBox box)
vm.PfxPasswordInput = box.Password;
}
private void OnModeChecked(object sender, RoutedEventArgs e)
{
if (DataContext is ServerSettingsViewModel vm && sender is RadioButton rb && rb.Tag is string tag)
+14 -7
View File
@@ -120,6 +120,7 @@ internal sealed class AdminPipeServer : BackgroundService
var incoming = DeserializeData<ServerConfig>(request) ?? throw new ArgumentException("missing config payload");
MergeWithExistingSecrets(incoming, _state.Snapshot());
await _state.ReplaceAsync(incoming, ct).ConfigureAwait(false);
_logger.LogInformation("Server config replaced ({Count} endpoints)", incoming.Endpoints.Count);
return AdminResponse.Success(SafeSnapshotForWire(_state.Snapshot()));
}
@@ -135,6 +136,7 @@ internal sealed class AdminPipeServer : BackgroundService
return AdminResponse.Failure($"slug '{ep.Slug}' already exists");
next.Endpoints.Add(ep);
await _state.ReplaceAsync(next, ct).ConfigureAwait(false);
_logger.LogInformation("Endpoint created: {Slug} ({Id})", ep.Slug, ep.Id);
return AdminResponse.Success(ep);
}
@@ -147,6 +149,7 @@ internal sealed class AdminPipeServer : BackgroundService
MergeEndpointSecrets(ep, next.Endpoints[idx]);
next.Endpoints[idx] = ep;
await _state.ReplaceAsync(next, ct).ConfigureAwait(false);
_logger.LogInformation("Endpoint updated: {Slug} ({Id})", ep.Slug, ep.Id);
return AdminResponse.Success(ep);
}
@@ -157,6 +160,7 @@ internal sealed class AdminPipeServer : BackgroundService
var removed = next.Endpoints.RemoveAll(e => e.Id == args.Id);
if (removed == 0) return AdminResponse.Failure("endpoint not found");
await _state.ReplaceAsync(next, ct).ConfigureAwait(false);
_logger.LogInformation("Endpoint deleted: {Id}", args.Id);
return AdminResponse.Success();
}
@@ -167,8 +171,10 @@ internal sealed class AdminPipeServer : BackgroundService
var next = CloneSnapshotForEdit();
var ep = next.Endpoints.FirstOrDefault(e => e.Id == args.Id);
if (ep is null) return AdminResponse.Failure("endpoint not found");
ep.Enabled = request.Op == AdminOps.EnableEndpoint;
var newState = request.Op == AdminOps.EnableEndpoint;
ep.Enabled = newState;
await _state.ReplaceAsync(next, ct).ConfigureAwait(false);
_logger.LogInformation("Endpoint {Slug} {State}", ep.Slug, newState ? "enabled" : "disabled");
return AdminResponse.Success(ep);
}
@@ -178,6 +184,8 @@ internal sealed class AdminPipeServer : BackgroundService
var next = CloneSnapshotForEdit();
next.HttpsBinding = binding;
await _state.ReplaceAsync(next, ct).ConfigureAwait(false);
_logger.LogInformation("HTTPS binding {Action}",
binding is null || binding.Kind == HttpsBindingKind.None ? "cleared" : $"set ({binding.Kind} on port {binding.Port})");
return AdminResponse.Success();
}
@@ -214,16 +222,15 @@ internal sealed class AdminPipeServer : BackgroundService
}
/// <summary>
/// Strip plaintext secrets from a snapshot before sending to the GUI. Encrypted
/// blobs are useless to the GUI but harmless; plaintext must never leak.
/// Deep-clone the snapshot for the GUI. Plaintext secrets ARE included on the
/// wire — the admin pipe is ACL'd to SYSTEM and Administrators, so anyone able
/// to read the wire already has full local privilege. Letting the GUI display
/// secrets means an admin can recover a lost token without resetting it.
/// </summary>
private static ServerConfig SafeSnapshotForWire(ServerConfig snap)
{
// Deep clone via JSON, then null out plaintext on the clone.
var json = JsonSerializer.Serialize(snap, ConfigJson.Compact);
var clone = JsonSerializer.Deserialize<ServerConfig>(json, ConfigJson.Compact)!;
ConfigStore.ClearPlaintexts(clone);
return clone;
return JsonSerializer.Deserialize<ServerConfig>(json, ConfigJson.Compact)!;
}
/// <summary>
+7 -1
View File
@@ -62,7 +62,10 @@ try
lifetime.StopApplication();
};
app.MapPost("/hook/{slug}", async (string slug, HttpContext http) =>
// Accept POST (the standard webhook verb) and GET (so a browser can smoke-test
// hooks without curl). GET requests will have an empty body, which the executor
// and arg-template renderer handle as if the body were empty JSON.
app.MapMethods("/hook/{slug}", new[] { "GET", "POST" }, async (string slug, HttpContext http) =>
{
var router = http.RequestServices.GetRequiredService<WebhookRouter>();
await router.HandleAsync(http, slug);
@@ -70,6 +73,9 @@ try
app.MapGet("/healthz", () => Results.Ok(new { ok = true }));
// Stop browsers from logging 404s for favicon.ico every time they hit a hook.
app.MapGet("/favicon.ico", () => Results.StatusCode(StatusCodes.Status204NoContent));
await app.RunAsync().ConfigureAwait(false);
}
catch (Exception ex)