diff --git a/scripts/deploy.ps1 b/scripts/deploy.ps1
new file mode 100644
index 0000000..c20b25d
--- /dev/null
+++ b/scripts/deploy.ps1
@@ -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"
diff --git a/scripts/install-service.ps1 b/scripts/install-service.ps1
index a6ef3af..a0a2ec5 100644
--- a/scripts/install-service.ps1
+++ b/scripts/install-service.ps1
@@ -48,23 +48,29 @@ if (-not (Test-Path -LiteralPath $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
$existing = sc.exe query $ServiceName 2>$null
if ($existing) {
Write-Host "Service '$ServiceName' already exists; updating binPath and account."
- sc.exe config $ServiceName binPath= "`"$BinaryPath`"" obj= $obj $(if ($Password) { "password= $Password" }) | Out-Null
-} else {
- $args = @(
- 'create', $ServiceName,
- "binPath=", "`"$BinaryPath`"",
- "DisplayName=", "`"$DisplayName`"",
- "start=", "auto",
- "obj=", $obj
+ $configArgs = @(
+ 'config', $ServiceName,
+ 'binPath=', "`"$BinaryPath`"",
+ 'obj=', $obj
)
- if ($Password) { $args += @('password=', $Password) }
- sc.exe @args | Out-Null
+ if ($Password) { $configArgs += @('password=', $Password) }
+ 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.
diff --git a/src/WebhookServer.Gui/App.xaml b/src/WebhookServer.Gui/App.xaml
index 5693866..6b883d4 100644
--- a/src/WebhookServer.Gui/App.xaml
+++ b/src/WebhookServer.Gui/App.xaml
@@ -7,5 +7,6 @@
+
diff --git a/src/WebhookServer.Gui/Converters/Converters.cs b/src/WebhookServer.Gui/Converters/Converters.cs
index bc3f6ad..a7b32c2 100644
--- a/src/WebhookServer.Gui/Converters/Converters.cs
+++ b/src/WebhookServer.Gui/Converters/Converters.cs
@@ -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)
diff --git a/src/WebhookServer.Gui/MainWindow.xaml b/src/WebhookServer.Gui/MainWindow.xaml
index 9925e9b..121535b 100644
--- a/src/WebhookServer.Gui/MainWindow.xaml
+++ b/src/WebhookServer.Gui/MainWindow.xaml
@@ -28,6 +28,10 @@
+
+
@@ -46,6 +50,11 @@
CanUserDeleteRows="False"
IsReadOnly="True"
HeadersVisibility="Column">
+
+
+
@@ -56,7 +65,25 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -71,16 +98,20 @@
+
-
+
+
-
+ TextWrapping="NoWrap"
+ TextChanged="OnLogTailChanged"/>
diff --git a/src/WebhookServer.Gui/MainWindow.xaml.cs b/src/WebhookServer.Gui/MainWindow.xaml.cs
index 7d372b1..3df0142 100644
--- a/src/WebhookServer.Gui/MainWindow.xaml.cs
+++ b/src/WebhookServer.Gui/MainWindow.xaml.cs
@@ -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);
+ }
}
diff --git a/src/WebhookServer.Gui/ViewModels/EndpointEditorViewModel.cs b/src/WebhookServer.Gui/ViewModels/EndpointEditorViewModel.cs
index 55f6496..dd63d0b 100644
--- a/src/WebhookServer.Gui/ViewModels/EndpointEditorViewModel.cs
+++ b/src/WebhookServer.Gui/ViewModels/EndpointEditorViewModel.cs
@@ -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));
+ ///
+ /// Proxy for that emits change notifications
+ /// for the visibility flags so the bearer/HMAC sections show/hide reactively.
+ ///
+ 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();
diff --git a/src/WebhookServer.Gui/ViewModels/MainViewModel.cs b/src/WebhookServer.Gui/ViewModels/MainViewModel.cs
index 569d679..29e76b9 100644
--- a/src/WebhookServer.Gui/ViewModels/MainViewModel.cs
+++ b/src/WebhookServer.Gui/ViewModels/MainViewModel.cs
@@ -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()
{
diff --git a/src/WebhookServer.Gui/ViewModels/ServerSettingsViewModel.cs b/src/WebhookServer.Gui/ViewModels/ServerSettingsViewModel.cs
index 12b65de..592baad 100644
--- a/src/WebhookServer.Gui/ViewModels/ServerSettingsViewModel.cs
+++ b/src/WebhookServer.Gui/ViewModels/ServerSettingsViewModel.cs
@@ -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;
}
diff --git a/src/WebhookServer.Gui/Views/EndpointEditor.xaml b/src/WebhookServer.Gui/Views/EndpointEditor.xaml
index 2b3048c..9afce5b 100644
--- a/src/WebhookServer.Gui/Views/EndpointEditor.xaml
+++ b/src/WebhookServer.Gui/Views/EndpointEditor.xaml
@@ -47,32 +47,38 @@
+ SelectedItem="{Binding SelectedAuthMode, Mode=TwoWay}"/>
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/WebhookServer.Gui/Views/EndpointEditor.xaml.cs b/src/WebhookServer.Gui/Views/EndpointEditor.xaml.cs
index 03dc75c..63248b7 100644
--- a/src/WebhookServer.Gui/Views/EndpointEditor.xaml.cs
+++ b/src/WebhookServer.Gui/Views/EndpointEditor.xaml.cs
@@ -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 */ }
}
}
diff --git a/src/WebhookServer.Gui/Views/ServerSettings.xaml b/src/WebhookServer.Gui/Views/ServerSettings.xaml
index fdd702a..7bd40b3 100644
--- a/src/WebhookServer.Gui/Views/ServerSettings.xaml
+++ b/src/WebhookServer.Gui/Views/ServerSettings.xaml
@@ -58,7 +58,7 @@
-
+
diff --git a/src/WebhookServer.Gui/Views/ServerSettings.xaml.cs b/src/WebhookServer.Gui/Views/ServerSettings.xaml.cs
index 3ed509f..ca8c982 100644
--- a/src/WebhookServer.Gui/Views/ServerSettings.xaml.cs
+++ b/src/WebhookServer.Gui/Views/ServerSettings.xaml.cs
@@ -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)
diff --git a/src/WebhookServer.Service/AdminPipeServer.cs b/src/WebhookServer.Service/AdminPipeServer.cs
index d9726f6..780b4e9 100644
--- a/src/WebhookServer.Service/AdminPipeServer.cs
+++ b/src/WebhookServer.Service/AdminPipeServer.cs
@@ -120,6 +120,7 @@ internal sealed class AdminPipeServer : BackgroundService
var incoming = DeserializeData(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
}
///
- /// 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.
///
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(json, ConfigJson.Compact)!;
- ConfigStore.ClearPlaintexts(clone);
- return clone;
+ return JsonSerializer.Deserialize(json, ConfigJson.Compact)!;
}
///
diff --git a/src/WebhookServer.Service/Program.cs b/src/WebhookServer.Service/Program.cs
index 85081da..4f28c3d 100644
--- a/src/WebhookServer.Service/Program.cs
+++ b/src/WebhookServer.Service/Program.cs
@@ -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();
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)