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