Initial WebhookServer implementation

Add the .NET 8 solution scaffolded against PLAN.md. Three projects share
WebhookServer.Core (models, auth, execution, storage, IPC, callbacks)
and WebhookServer.Service hosts an embedded Kestrel listener plus the
named-pipe admin server. WebhookServer.Gui is a thin MVVM client over
the pipe. Includes 25 unit tests covering HMAC verification, bearer
auth, IP allowlist parsing, arg-template rendering, DPAPI round-trip,
and the encrypt-on-save config store.

Install/uninstall PowerShell scripts default to LocalSystem and accept
a domain user or gMSA via -ServiceAccount.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-07 22:04:52 -04:00
parent 2f61b342af
commit 8ecfe84540
62 changed files with 3721 additions and 0 deletions
@@ -0,0 +1,76 @@
using System.Runtime.Versioning;
using System.Text.Json;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using WebhookServer.Core.Models;
using WebhookServer.Core.Storage;
namespace WebhookServer.Gui.ViewModels;
[SupportedOSPlatform("windows")]
public sealed partial class EndpointEditorViewModel : ObservableObject
{
public EndpointConfig Endpoint { get; }
public bool IsNew { get; }
[ObservableProperty] private bool _accepted;
public EndpointEditorViewModel(EndpointConfig template, bool isNew)
{
// Deep clone via JSON so cancel-on-close cleanly drops edits.
var json = JsonSerializer.Serialize(template, ConfigJson.Compact);
Endpoint = JsonSerializer.Deserialize<EndpointConfig>(json, ConfigJson.Compact)!;
Endpoint.Bearer ??= new BearerOptions();
Endpoint.Hmac ??= new HmacOptions();
IsNew = isNew;
}
public Array AuthModes { get; } = Enum.GetValues(typeof(AuthMode));
public Array ExecutorTypes { get; } = Enum.GetValues(typeof(ExecutorType));
public Array ResponseModes { get; } = Enum.GetValues(typeof(ResponseMode));
public string AllowedClientsText
{
get => string.Join(Environment.NewLine, Endpoint.AllowedClients);
set
{
Endpoint.AllowedClients = (value ?? "").Split(new[] { '\r', '\n', ',' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
OnPropertyChanged();
}
}
public string ExecutableArgsText
{
get => string.Join(" ", Endpoint.ExecutableArgs);
set
{
Endpoint.ExecutableArgs = (value ?? "").Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
OnPropertyChanged();
}
}
public string BearerSecretInput
{
get => "";
set
{
Endpoint.Bearer ??= new BearerOptions();
Endpoint.Bearer.Secret.Plaintext = string.IsNullOrEmpty(value) ? null : value;
OnPropertyChanged();
}
}
public string HmacSecretInput
{
get => "";
set
{
Endpoint.Hmac ??= new HmacOptions();
Endpoint.Hmac.Secret.Plaintext = string.IsNullOrEmpty(value) ? null : value;
OnPropertyChanged();
}
}
[RelayCommand]
private void Save() => Accepted = true;
}
@@ -0,0 +1,189 @@
using System.Collections.ObjectModel;
using System.Runtime.Versioning;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using WebhookServer.Core.Ipc;
using WebhookServer.Core.Models;
using WebhookServer.Gui.Services;
using WebhookServer.Gui.Views;
namespace WebhookServer.Gui.ViewModels;
[SupportedOSPlatform("windows")]
public sealed partial class MainViewModel : ObservableObject
{
private readonly AdminPipeClient _client;
public ObservableCollection<EndpointConfig> Endpoints { get; } = new();
[ObservableProperty] private EndpointConfig? _selectedEndpoint;
[ObservableProperty] private string _connectionStatus = "Disconnected";
[ObservableProperty] private bool _isConnected;
[ObservableProperty] private string _logTail = "";
[ObservableProperty] private ServerConfig _serverConfig = new();
public MainViewModel(AdminPipeClient client)
{
_client = client;
}
[RelayCommand]
private async Task RefreshAsync()
{
try
{
var status = await _client.GetStatusAsync().ConfigureAwait(false);
var config = await _client.GetConfigAsync().ConfigureAwait(false);
Application.Current.Dispatcher.Invoke(() =>
{
IsConnected = status?.Running == true;
ConnectionStatus = IsConnected
? $"Connected — HTTP {status!.HttpPort}{(status.HttpsPort.HasValue ? $" / HTTPS {status.HttpsPort}" : "")}"
: "Disconnected";
Endpoints.Clear();
if (config is not null)
{
ServerConfig = config;
foreach (var ep in config.Endpoints) Endpoints.Add(ep);
}
});
}
catch (Exception ex)
{
Application.Current.Dispatcher.Invoke(() =>
{
IsConnected = false;
ConnectionStatus = $"Disconnected: {ex.Message}";
});
}
await RefreshLogTailAsync().ConfigureAwait(false);
}
[RelayCommand]
private async Task RefreshLogTailAsync()
{
try
{
var lines = await _client.TailLogsAsync(100).ConfigureAwait(false);
var text = new StringBuilder();
foreach (var line in lines) text.AppendLine(line.Message);
Application.Current.Dispatcher.Invoke(() => LogTail = text.ToString());
}
catch
{
// ignore — main connection state already reflects pipe failure
}
}
[RelayCommand]
private async Task AddEndpointAsync()
{
var draft = new EndpointConfig { Id = Guid.NewGuid(), Slug = "new-hook" };
var dlg = new EndpointEditor { Owner = Application.Current.MainWindow };
var vm = new EndpointEditorViewModel(draft, isNew: true);
dlg.DataContext = vm;
if (dlg.ShowDialog() != true) return;
try
{
await _client.CreateEndpointAsync(vm.Endpoint).ConfigureAwait(false);
await RefreshAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
ShowError("Create failed", ex);
}
}
[RelayCommand]
private async Task EditEndpointAsync()
{
if (SelectedEndpoint is null) return;
var dlg = new EndpointEditor { Owner = Application.Current.MainWindow };
var vm = new EndpointEditorViewModel(SelectedEndpoint, isNew: false);
dlg.DataContext = vm;
if (dlg.ShowDialog() != true) return;
try
{
await _client.UpdateEndpointAsync(vm.Endpoint).ConfigureAwait(false);
await RefreshAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
ShowError("Update failed", ex);
}
}
[RelayCommand]
private async Task DeleteEndpointAsync()
{
if (SelectedEndpoint is null) return;
var ok = MessageBox.Show(
$"Delete endpoint '{SelectedEndpoint.Slug}'?",
"Confirm",
MessageBoxButton.OKCancel,
MessageBoxImage.Warning);
if (ok != MessageBoxResult.OK) return;
try
{
await _client.DeleteEndpointAsync(SelectedEndpoint.Id).ConfigureAwait(false);
await RefreshAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
ShowError("Delete failed", ex);
}
}
[RelayCommand]
private async Task ToggleEnabledAsync(EndpointConfig? ep)
{
if (ep is null) return;
try
{
await _client.SetEndpointEnabledAsync(ep.Id, !ep.Enabled).ConfigureAwait(false);
await RefreshAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
ShowError("Toggle failed", ex);
}
}
[RelayCommand]
private async Task EditServerSettingsAsync()
{
var dlg = new ServerSettings { Owner = Application.Current.MainWindow };
var vm = new ServerSettingsViewModel(ServerConfig);
dlg.DataContext = vm;
if (dlg.ShowDialog() != true) return;
try
{
ServerConfig.HttpPort = vm.HttpPort;
ServerConfig.TrustedProxies = vm.TrustedProxiesList;
ServerConfig.HttpsBinding = vm.BuildBinding();
await _client.InvokeAsync(AdminOps.UpdateConfig, ServerConfig).ConfigureAwait(false);
await RefreshAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
ShowError("Save failed", ex);
}
}
private static void ShowError(string title, Exception ex)
{
Application.Current.Dispatcher.Invoke(() =>
MessageBox.Show(ex.Message, title, MessageBoxButton.OK, MessageBoxImage.Error));
}
}
@@ -0,0 +1,60 @@
using System.Runtime.Versioning;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using WebhookServer.Core.Models;
namespace WebhookServer.Gui.ViewModels;
[SupportedOSPlatform("windows")]
public sealed partial class ServerSettingsViewModel : ObservableObject
{
[ObservableProperty] private int _httpPort;
[ObservableProperty] private int _httpsPort;
[ObservableProperty] private bool _httpsEnabled;
[ObservableProperty] private string _httpsMode = "PfxFile";
[ObservableProperty] private string _pfxPath = "";
[ObservableProperty] private string _pfxPasswordInput = "";
[ObservableProperty] private string _thumbprint = "";
[ObservableProperty] private string _trustedProxiesText = "";
public bool Accepted { get; private set; }
public ServerSettingsViewModel(ServerConfig config)
{
HttpPort = config.HttpPort;
TrustedProxiesText = string.Join(Environment.NewLine, config.TrustedProxies);
var b = config.HttpsBinding;
HttpsEnabled = b is not null && b.Kind != HttpsBindingKind.None;
HttpsPort = b?.Port ?? 8443;
HttpsMode = b?.Kind == HttpsBindingKind.CertStoreThumbprint ? "Thumbprint" : "PfxFile";
PfxPath = b?.PfxPath ?? "";
Thumbprint = b?.Thumbprint ?? "";
}
public List<string> TrustedProxiesList =>
(TrustedProxiesText ?? "").Split(new[] { '\r', '\n', ',' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
public HttpsBinding? BuildBinding()
{
if (!HttpsEnabled) return null;
var binding = new HttpsBinding { Port = HttpsPort };
if (string.Equals(HttpsMode, "Thumbprint", StringComparison.OrdinalIgnoreCase))
{
binding.Kind = HttpsBindingKind.CertStoreThumbprint;
binding.Thumbprint = Thumbprint?.Trim();
}
else
{
binding.Kind = HttpsBindingKind.PfxFile;
binding.PfxPath = PfxPath;
if (!string.IsNullOrEmpty(PfxPasswordInput))
binding.PfxPassword = ProtectedString.FromPlaintext(PfxPasswordInput);
}
return binding;
}
[RelayCommand]
private void Save() => Accepted = true;
}