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:
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user