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
+102
View File
@@ -0,0 +1,102 @@
<#
.SYNOPSIS
Builds, publishes, copies, installs, and starts WebhookServer as a Windows Service
running under LocalSystem.
.DESCRIPTION
Idempotent — safe to re-run after code changes. Stops the service first so binaries
aren't locked, copies the latest published output to InstallRoot, then re-creates or
re-configures the service and starts it.
Must be run from an elevated PowerShell.
.PARAMETER InstallRoot
Where the binaries get copied. Defaults to "C:\Program Files\WebhookServer".
.PARAMETER ServiceAccount
Service identity. Defaults to LocalSystem. For AD-aware hooks pass a domain user
or gMSA — see the Service account section in README.md.
.PARAMETER SkipBuild
Skip the dotnet publish step (use the existing publish\ output as-is).
.EXAMPLE
# First-time install (and after any code change)
.\deploy.ps1
.EXAMPLE
# Run service under a gMSA
.\deploy.ps1 -ServiceAccount 'CONTOSO\svc-webhookserver$'
#>
[CmdletBinding()]
param(
[string]$InstallRoot = 'C:\Program Files\WebhookServer',
[string]$ServiceName = 'WebhookServer',
[string]$ServiceAccount = 'LocalSystem',
[string]$Password,
[switch]$SkipBuild
)
$ErrorActionPreference = 'Stop'
$principal = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent())
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
throw 'deploy.ps1 must be run from an elevated PowerShell.'
}
$repoRoot = Split-Path -Parent $PSScriptRoot
$publishSvc = Join-Path $repoRoot 'publish\service'
$publishGui = Join-Path $repoRoot 'publish\gui'
# 1. Stop the service if it's already installed so its binaries aren't locked.
$svc = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
if ($svc -and $svc.Status -ne 'Stopped') {
Write-Host "Stopping existing service '$ServiceName'..."
Stop-Service -Name $ServiceName -Force
$svc.WaitForStatus('Stopped', '00:00:30')
}
# Belt-and-braces: kill any orphan dev-launch processes still holding the binaries.
Get-Process -Name 'WebhookServer.Service','WebhookServer.Gui' -ErrorAction SilentlyContinue |
ForEach-Object { try { $_ | Stop-Process -Force } catch { } }
# 2. Publish (unless told to skip).
if (-not $SkipBuild) {
Write-Host 'Publishing service + GUI...'
& dotnet publish (Join-Path $repoRoot 'src\WebhookServer.Service\WebhookServer.Service.csproj') `
-c Release -r win-x64 --self-contained false -o $publishSvc | Out-Host
if ($LASTEXITCODE -ne 0) { throw 'service publish failed' }
& dotnet publish (Join-Path $repoRoot 'src\WebhookServer.Gui\WebhookServer.Gui.csproj') `
-c Release -r win-x64 --self-contained false -o $publishGui | Out-Host
if ($LASTEXITCODE -ne 0) { throw 'GUI publish failed' }
}
# 3. Copy binaries into InstallRoot.
Write-Host "Copying binaries to $InstallRoot..."
New-Item -ItemType Directory -Path $InstallRoot -Force | Out-Null
Copy-Item -Path (Join-Path $publishSvc '*') -Destination $InstallRoot -Recurse -Force
Copy-Item -Path (Join-Path $publishGui '*') -Destination $InstallRoot -Recurse -Force
$serviceExe = Join-Path $InstallRoot 'WebhookServer.Service.exe'
$guiExe = Join-Path $InstallRoot 'WebhookServer.Gui.exe'
# 4. Create or update the Windows Service via install-service.ps1.
$installArgs = @{
BinaryPath = $serviceExe
ServiceName = $ServiceName
ServiceAccount = $ServiceAccount
}
if ($PSBoundParameters.ContainsKey('Password')) { $installArgs.Password = $Password }
& (Join-Path $PSScriptRoot 'install-service.ps1') @installArgs
# 5. Show how to launch the GUI.
Write-Host ''
Write-Host '=== Deployed ===' -ForegroundColor Green
Write-Host " Service exe : $serviceExe"
Write-Host " GUI exe : $guiExe"
Write-Host " Config : $env:ProgramData\WebhookServer\config.json"
Write-Host " Logs : $env:ProgramData\WebhookServer\logs"
Write-Host ''
Write-Host 'Launch the GUI (must stay elevated to talk to the admin pipe):'
Write-Host " Start-Process -FilePath '$guiExe' -Verb RunAs"
+17 -11
View File
@@ -48,23 +48,29 @@ if (-not (Test-Path -LiteralPath $BinaryPath)) {
throw "Binary not found: $BinaryPath" throw "Binary not found: $BinaryPath"
} }
# Build sc.exe argv. Note: sc.exe is fussy about spaces — keep "key= value" format. # sc.exe argv format: "key= value" — space AFTER equals, none before.
$obj = $ServiceAccount $obj = $ServiceAccount
$existing = sc.exe query $ServiceName 2>$null $existing = sc.exe query $ServiceName 2>$null
if ($existing) { if ($existing) {
Write-Host "Service '$ServiceName' already exists; updating binPath and account." Write-Host "Service '$ServiceName' already exists; updating binPath and account."
sc.exe config $ServiceName binPath= "`"$BinaryPath`"" obj= $obj $(if ($Password) { "password= $Password" }) | Out-Null $configArgs = @(
} else { 'config', $ServiceName,
$args = @( 'binPath=', "`"$BinaryPath`"",
'create', $ServiceName, 'obj=', $obj
"binPath=", "`"$BinaryPath`"",
"DisplayName=", "`"$DisplayName`"",
"start=", "auto",
"obj=", $obj
) )
if ($Password) { $args += @('password=', $Password) } if ($Password) { $configArgs += @('password=', $Password) }
sc.exe @args | Out-Null sc.exe @configArgs | Out-Null
} else {
$createArgs = @(
'create', $ServiceName,
'binPath=', "`"$BinaryPath`"",
'DisplayName=', "`"$DisplayName`"",
'start=', 'auto',
'obj=', $obj
)
if ($Password) { $createArgs += @('password=', $Password) }
sc.exe @createArgs | Out-Null
} }
# Configure failure recovery: restart the service on first/second failure, reset count after a day. # Configure failure recovery: restart the service on first/second failure, reset count after a day.
+1
View File
@@ -7,5 +7,6 @@
<conv:NullToBoolConverter x:Key="NotNull"/> <conv:NullToBoolConverter x:Key="NotNull"/>
<conv:BoolToBrushConverter x:Key="ConnFill"/> <conv:BoolToBrushConverter x:Key="ConnFill"/>
<conv:StringEqualsConverter x:Key="StringEqualsConverter"/> <conv:StringEqualsConverter x:Key="StringEqualsConverter"/>
<conv:HookUrlConverter x:Key="HookUrl"/>
</Application.Resources> </Application.Resources>
</Application> </Application>
@@ -13,6 +13,21 @@ public sealed class NullToBoolConverter : IValueConverter
=> throw new NotSupportedException(); => 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 sealed class StringEqualsConverter : IValueConverter
{ {
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) 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}" <Button Content="Delete" Command="{Binding DeleteEndpointCommand}"
IsEnabled="{Binding SelectedEndpoint, Converter={StaticResource NotNull}}"/> IsEnabled="{Binding SelectedEndpoint, Converter={StaticResource NotNull}}"/>
<Separator/> <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}"/> <Button Content="Server Settings…" Command="{Binding EditServerSettingsCommand}"/>
</ToolBar> </ToolBar>
@@ -46,6 +50,11 @@
CanUserDeleteRows="False" CanUserDeleteRows="False"
IsReadOnly="True" IsReadOnly="True"
HeadersVisibility="Column"> HeadersVisibility="Column">
<DataGrid.RowStyle>
<Style TargetType="DataGridRow">
<EventSetter Event="MouseDoubleClick" Handler="OnRowDoubleClick"/>
</Style>
</DataGrid.RowStyle>
<DataGrid.Columns> <DataGrid.Columns>
<DataGridTemplateColumn Header="Enabled" Width="80"> <DataGridTemplateColumn Header="Enabled" Width="80">
<DataGridTemplateColumn.CellTemplate> <DataGridTemplateColumn.CellTemplate>
@@ -56,7 +65,25 @@
</DataTemplate> </DataTemplate>
</DataGridTemplateColumn.CellTemplate> </DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn> </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="Auth" Binding="{Binding AuthMode}" Width="80"/>
<DataGridTextColumn Header="Executor" Binding="{Binding ExecutorType}" Width="140"/> <DataGridTextColumn Header="Executor" Binding="{Binding ExecutorType}" Width="140"/>
<DataGridTextColumn Header="Mode" Binding="{Binding ResponseMode}" Width="80"/> <DataGridTextColumn Header="Mode" Binding="{Binding ResponseMode}" Width="80"/>
@@ -71,16 +98,20 @@
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/> <ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/> <ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<TextBlock Text="Recent log entries" FontWeight="Bold" Margin="6,4"/> <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> </Grid>
<TextBox Text="{Binding LogTail, Mode=OneWay}" <TextBox x:Name="LogTailBox"
Text="{Binding LogTail, Mode=OneWay}"
IsReadOnly="True" IsReadOnly="True"
FontFamily="Consolas" FontFamily="Consolas"
VerticalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Auto"
TextWrapping="NoWrap"/> TextWrapping="NoWrap"
TextChanged="OnLogTailChanged"/>
</DockPanel> </DockPanel>
</Grid> </Grid>
</DockPanel> </DockPanel>
+14
View File
@@ -1,4 +1,6 @@
using System.Windows; using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using WebhookServer.Gui.Services; using WebhookServer.Gui.Services;
using WebhookServer.Gui.ViewModels; using WebhookServer.Gui.ViewModels;
@@ -13,4 +15,16 @@ public partial class MainWindow : Window
DataContext = vm; DataContext = vm;
Loaded += async (_, _) => await vm.RefreshCommand.ExecuteAsync(null); 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.Runtime.Versioning;
using System.Text.Json; using System.Text.Json;
using System.Windows;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using WebhookServer.Core.Models; using WebhookServer.Core.Models;
@@ -29,6 +30,29 @@ public sealed partial class EndpointEditorViewModel : ObservableObject
public Array ExecutorTypes { get; } = Enum.GetValues(typeof(ExecutorType)); public Array ExecutorTypes { get; } = Enum.GetValues(typeof(ExecutorType));
public Array ResponseModes { get; } = Enum.GetValues(typeof(ResponseMode)); 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 public string AllowedClientsText
{ {
get => string.Join(Environment.NewLine, Endpoint.AllowedClients); 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 set
{ {
Endpoint.Bearer ??= new BearerOptions(); 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 set
{ {
Endpoint.Hmac ??= new HmacOptions(); Endpoint.Hmac ??= new HmacOptions();
@@ -4,6 +4,7 @@ using System.Text;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using System.Windows; using System.Windows;
using System.Windows.Threading;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using WebhookServer.Core.Ipc; using WebhookServer.Core.Ipc;
@@ -24,11 +25,19 @@ public sealed partial class MainViewModel : ObservableObject
[ObservableProperty] private string _connectionStatus = "Disconnected"; [ObservableProperty] private string _connectionStatus = "Disconnected";
[ObservableProperty] private bool _isConnected; [ObservableProperty] private bool _isConnected;
[ObservableProperty] private string _logTail = ""; [ObservableProperty] private string _logTail = "";
[ObservableProperty] private bool _autoScrollLogs = true;
[ObservableProperty] private ServerConfig _serverConfig = new(); [ObservableProperty] private ServerConfig _serverConfig = new();
[ObservableProperty] private string _httpBaseUrl = "http://localhost:8080";
[ObservableProperty] private string? _httpsBaseUrl;
private readonly DispatcherTimer _logTimer;
public MainViewModel(AdminPipeClient client) public MainViewModel(AdminPipeClient client)
{ {
_client = client; _client = client;
_logTimer = new DispatcherTimer(DispatcherPriority.Background) { Interval = TimeSpan.FromSeconds(3) };
_logTimer.Tick += async (_, _) => await RefreshLogTailAsync();
_logTimer.Start();
} }
[RelayCommand] [RelayCommand]
@@ -46,6 +55,12 @@ public sealed partial class MainViewModel : ObservableObject
? $"Connected — HTTP {status!.HttpPort}{(status.HttpsPort.HasValue ? $" / HTTPS {status.HttpsPort}" : "")}" ? $"Connected — HTTP {status!.HttpPort}{(status.HttpsPort.HasValue ? $" / HTTPS {status.HttpsPort}" : "")}"
: "Disconnected"; : "Disconnected";
if (status is not null)
{
HttpBaseUrl = $"http://localhost:{status.HttpPort}";
HttpsBaseUrl = status.HttpsPort.HasValue ? $"https://localhost:{status.HttpsPort.Value}" : null;
}
Endpoints.Clear(); Endpoints.Clear();
if (config is not null) 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] [RelayCommand]
private async Task EditServerSettingsAsync() private async Task EditServerSettingsAsync()
{ {
@@ -13,7 +13,7 @@ public sealed partial class ServerSettingsViewModel : ObservableObject
[ObservableProperty] private bool _httpsEnabled; [ObservableProperty] private bool _httpsEnabled;
[ObservableProperty] private string _httpsMode = "PfxFile"; [ObservableProperty] private string _httpsMode = "PfxFile";
[ObservableProperty] private string _pfxPath = ""; [ObservableProperty] private string _pfxPath = "";
[ObservableProperty] private string _pfxPasswordInput = ""; [ObservableProperty] private string _pfxPassword = "";
[ObservableProperty] private string _thumbprint = ""; [ObservableProperty] private string _thumbprint = "";
[ObservableProperty] private string _trustedProxiesText = ""; [ObservableProperty] private string _trustedProxiesText = "";
@@ -29,6 +29,7 @@ public sealed partial class ServerSettingsViewModel : ObservableObject
HttpsPort = b?.Port ?? 8443; HttpsPort = b?.Port ?? 8443;
HttpsMode = b?.Kind == HttpsBindingKind.CertStoreThumbprint ? "Thumbprint" : "PfxFile"; HttpsMode = b?.Kind == HttpsBindingKind.CertStoreThumbprint ? "Thumbprint" : "PfxFile";
PfxPath = b?.PfxPath ?? ""; PfxPath = b?.PfxPath ?? "";
PfxPassword = b?.PfxPassword?.Plaintext ?? "";
Thumbprint = b?.Thumbprint ?? ""; Thumbprint = b?.Thumbprint ?? "";
} }
@@ -49,8 +50,8 @@ public sealed partial class ServerSettingsViewModel : ObservableObject
{ {
binding.Kind = HttpsBindingKind.PfxFile; binding.Kind = HttpsBindingKind.PfxFile;
binding.PfxPath = PfxPath; binding.PfxPath = PfxPath;
if (!string.IsNullOrEmpty(PfxPasswordInput)) if (!string.IsNullOrEmpty(PfxPassword))
binding.PfxPassword = ProtectedString.FromPlaintext(PfxPasswordInput); binding.PfxPassword = ProtectedString.FromPlaintext(PfxPassword);
} }
return binding; return binding;
} }
+25 -19
View File
@@ -47,32 +47,38 @@
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<TextBlock Text="Mode" VerticalAlignment="Center"/> <TextBlock Text="Mode" VerticalAlignment="Center"/>
<ComboBox Grid.Column="1" ItemsSource="{Binding AuthModes}" <ComboBox Grid.Column="1" ItemsSource="{Binding AuthModes}"
SelectedItem="{Binding Endpoint.AuthMode, Mode=TwoWay}"/> SelectedItem="{Binding SelectedAuthMode, Mode=TwoWay}"/>
</Grid> </Grid>
<Grid Margin="0,4,0,0"> <Grid Margin="0,4,0,0" Visibility="{Binding BearerVisible}">
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="120"/> <ColumnDefinition Width="120"/>
<ColumnDefinition Width="*"/> <ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<TextBlock Text="Bearer secret" VerticalAlignment="Center"/> <TextBlock Text="Bearer secret" VerticalAlignment="Center"/>
<PasswordBox Grid.Column="1" PasswordChanged="OnBearerPasswordChanged"/> <TextBox Grid.Column="1" Text="{Binding BearerSecret, UpdateSourceTrigger=PropertyChanged}" FontFamily="Consolas"/>
</Grid> <Button Grid.Column="2" Content="Copy" Margin="4,0,0,0" Padding="6,0" Click="OnCopyBearer"/>
<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}"/>
</Grid> </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> </StackPanel>
</GroupBox> </GroupBox>
@@ -19,15 +19,15 @@ public partial class EndpointEditor : Window
Close(); Close();
} }
private void OnBearerPasswordChanged(object sender, RoutedEventArgs e) private void OnCopyBearer(object sender, RoutedEventArgs e)
{ {
if (DataContext is EndpointEditorViewModel vm && sender is PasswordBox box) if (DataContext is EndpointEditorViewModel vm && !string.IsNullOrEmpty(vm.BearerSecret))
vm.BearerSecretInput = box.Password; 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) if (DataContext is EndpointEditorViewModel vm && !string.IsNullOrEmpty(vm.HmacSecret))
vm.HmacSecretInput = box.Password; 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"/> <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"/> <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"/> <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"/> <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(); 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) private void OnModeChecked(object sender, RoutedEventArgs e)
{ {
if (DataContext is ServerSettingsViewModel vm && sender is RadioButton rb && rb.Tag is string tag) 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"); var incoming = DeserializeData<ServerConfig>(request) ?? throw new ArgumentException("missing config payload");
MergeWithExistingSecrets(incoming, _state.Snapshot()); MergeWithExistingSecrets(incoming, _state.Snapshot());
await _state.ReplaceAsync(incoming, ct).ConfigureAwait(false); await _state.ReplaceAsync(incoming, ct).ConfigureAwait(false);
_logger.LogInformation("Server config replaced ({Count} endpoints)", incoming.Endpoints.Count);
return AdminResponse.Success(SafeSnapshotForWire(_state.Snapshot())); return AdminResponse.Success(SafeSnapshotForWire(_state.Snapshot()));
} }
@@ -135,6 +136,7 @@ internal sealed class AdminPipeServer : BackgroundService
return AdminResponse.Failure($"slug '{ep.Slug}' already exists"); return AdminResponse.Failure($"slug '{ep.Slug}' already exists");
next.Endpoints.Add(ep); next.Endpoints.Add(ep);
await _state.ReplaceAsync(next, ct).ConfigureAwait(false); await _state.ReplaceAsync(next, ct).ConfigureAwait(false);
_logger.LogInformation("Endpoint created: {Slug} ({Id})", ep.Slug, ep.Id);
return AdminResponse.Success(ep); return AdminResponse.Success(ep);
} }
@@ -147,6 +149,7 @@ internal sealed class AdminPipeServer : BackgroundService
MergeEndpointSecrets(ep, next.Endpoints[idx]); MergeEndpointSecrets(ep, next.Endpoints[idx]);
next.Endpoints[idx] = ep; next.Endpoints[idx] = ep;
await _state.ReplaceAsync(next, ct).ConfigureAwait(false); await _state.ReplaceAsync(next, ct).ConfigureAwait(false);
_logger.LogInformation("Endpoint updated: {Slug} ({Id})", ep.Slug, ep.Id);
return AdminResponse.Success(ep); return AdminResponse.Success(ep);
} }
@@ -157,6 +160,7 @@ internal sealed class AdminPipeServer : BackgroundService
var removed = next.Endpoints.RemoveAll(e => e.Id == args.Id); var removed = next.Endpoints.RemoveAll(e => e.Id == args.Id);
if (removed == 0) return AdminResponse.Failure("endpoint not found"); if (removed == 0) return AdminResponse.Failure("endpoint not found");
await _state.ReplaceAsync(next, ct).ConfigureAwait(false); await _state.ReplaceAsync(next, ct).ConfigureAwait(false);
_logger.LogInformation("Endpoint deleted: {Id}", args.Id);
return AdminResponse.Success(); return AdminResponse.Success();
} }
@@ -167,8 +171,10 @@ internal sealed class AdminPipeServer : BackgroundService
var next = CloneSnapshotForEdit(); var next = CloneSnapshotForEdit();
var ep = next.Endpoints.FirstOrDefault(e => e.Id == args.Id); var ep = next.Endpoints.FirstOrDefault(e => e.Id == args.Id);
if (ep is null) return AdminResponse.Failure("endpoint not found"); 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); await _state.ReplaceAsync(next, ct).ConfigureAwait(false);
_logger.LogInformation("Endpoint {Slug} {State}", ep.Slug, newState ? "enabled" : "disabled");
return AdminResponse.Success(ep); return AdminResponse.Success(ep);
} }
@@ -178,6 +184,8 @@ internal sealed class AdminPipeServer : BackgroundService
var next = CloneSnapshotForEdit(); var next = CloneSnapshotForEdit();
next.HttpsBinding = binding; next.HttpsBinding = binding;
await _state.ReplaceAsync(next, ct).ConfigureAwait(false); 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(); return AdminResponse.Success();
} }
@@ -214,16 +222,15 @@ internal sealed class AdminPipeServer : BackgroundService
} }
/// <summary> /// <summary>
/// Strip plaintext secrets from a snapshot before sending to the GUI. Encrypted /// Deep-clone the snapshot for the GUI. Plaintext secrets ARE included on the
/// blobs are useless to the GUI but harmless; plaintext must never leak. /// 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> /// </summary>
private static ServerConfig SafeSnapshotForWire(ServerConfig snap) 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 json = JsonSerializer.Serialize(snap, ConfigJson.Compact);
var clone = JsonSerializer.Deserialize<ServerConfig>(json, ConfigJson.Compact)!; return JsonSerializer.Deserialize<ServerConfig>(json, ConfigJson.Compact)!;
ConfigStore.ClearPlaintexts(clone);
return clone;
} }
/// <summary> /// <summary>
+7 -1
View File
@@ -62,7 +62,10 @@ try
lifetime.StopApplication(); 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>(); var router = http.RequestServices.GetRequiredService<WebhookRouter>();
await router.HandleAsync(http, slug); await router.HandleAsync(http, slug);
@@ -70,6 +73,9 @@ try
app.MapGet("/healthz", () => Results.Ok(new { ok = true })); 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); await app.RunAsync().ConfigureAwait(false);
} }
catch (Exception ex) catch (Exception ex)