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
+116
View File
@@ -0,0 +1,116 @@
using System.Runtime.Versioning;
using System.Security.Cryptography.X509Certificates;
using Serilog;
using WebhookServer.Core.Callbacks;
using WebhookServer.Core.Execution;
using WebhookServer.Core.Models;
using WebhookServer.Core.Storage;
using WebhookServer.Service;
[assembly: SupportedOSPlatform("windows")]
Directory.CreateDirectory(ServicePaths.DataRoot);
Directory.CreateDirectory(ServicePaths.LogsDir);
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.Enrich.FromLogContext()
.WriteTo.Async(a => a.File(
ServicePaths.LogFileTemplate,
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 14,
outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}"))
.CreateLogger();
try
{
Log.Information("Starting WebhookServer.Service");
var builder = WebApplication.CreateBuilder(args);
builder.Host.UseWindowsService(o => o.ServiceName = "WebhookServer");
builder.Host.UseSerilog();
var configStore = new ConfigStore(ServicePaths.ConfigPath);
var initialConfig = await configStore.LoadAsync().ConfigureAwait(false);
ConfigStore.DecryptSecrets(initialConfig);
builder.WebHost.ConfigureKestrel(opts =>
{
opts.ListenAnyIP(initialConfig.HttpPort);
ConfigureHttps(opts, initialConfig.HttpsBinding);
});
builder.Services.AddSingleton(configStore);
builder.Services.AddSingleton<ServiceState>();
builder.Services.AddSingleton<IExecutor, ProcessExecutor>();
builder.Services.AddSingleton<ConcurrencyGate>();
builder.Services.AddSingleton<CallbackDispatcher>(sp =>
new CallbackDispatcher(sp.GetService<Microsoft.Extensions.Logging.ILogger<CallbackDispatcher>>()));
builder.Services.AddSingleton<WebhookRouter>();
builder.Services.AddHostedService<CallbackBackgroundService>();
builder.Services.AddHostedService<AdminPipeServer>();
var app = builder.Build();
var state = app.Services.GetRequiredService<ServiceState>();
await state.LoadAsync().ConfigureAwait(false);
var lifetime = app.Services.GetRequiredService<IHostApplicationLifetime>();
state.ListenerSettingsChanged += (_, _) =>
{
Log.Information("Listener settings changed; stopping service for restart.");
lifetime.StopApplication();
};
app.MapPost("/hook/{slug}", async (string slug, HttpContext http) =>
{
var router = http.RequestServices.GetRequiredService<WebhookRouter>();
await router.HandleAsync(http, slug);
});
app.MapGet("/healthz", () => Results.Ok(new { ok = true }));
await app.RunAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
Log.Fatal(ex, "Service terminated unexpectedly");
}
finally
{
Log.CloseAndFlush();
}
static void ConfigureHttps(Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerOptions opts, HttpsBinding? binding)
{
if (binding is null || binding.Kind == HttpsBindingKind.None) return;
X509Certificate2? cert = null;
switch (binding.Kind)
{
case HttpsBindingKind.PfxFile:
if (string.IsNullOrEmpty(binding.PfxPath)) return;
var password = binding.PfxPassword?.Plaintext;
cert = string.IsNullOrEmpty(password)
? new X509Certificate2(binding.PfxPath)
: new X509Certificate2(binding.PfxPath, password);
break;
case HttpsBindingKind.CertStoreThumbprint:
if (string.IsNullOrEmpty(binding.Thumbprint)) return;
using (var store = new X509Store(StoreName.My, binding.StoreLocation))
{
store.Open(OpenFlags.ReadOnly);
var matches = store.Certificates.Find(X509FindType.FindByThumbprint, binding.Thumbprint, validOnly: false);
if (matches.Count > 0) cert = matches[0];
}
break;
}
if (cert is null)
{
Log.Warning("HTTPS binding configured but no certificate was loaded; HTTPS endpoint will not be enabled.");
return;
}
opts.ListenAnyIP(binding.Port, listen => listen.UseHttps(cert));
}