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