Files
webhook-server/src/WebhookServer.Service/Program.cs
T
justin 28479272d5 Configurable bind addresses + display host in Server Settings
ServerConfig grows two fields:
- BindAddresses: list of IPs Kestrel binds to (empty = all interfaces,
  current behavior). Listening only on a subset is useful when the host
  has multiple NICs and the webhook should not be reachable on all of
  them.
- DisplayHost: the hostname/IP the GUI splices into the URL column and
  Copy URL button. Cosmetic; doesn't affect what the server accepts.

Server Settings dialog gains a "Network" section: a checkbox for "all
interfaces" plus per-NIC checkboxes auto-detected via NetworkInterface.
GetAllNetworkInterfaces, and an editable ComboBox for the display host
pre-populated with detected IPs and the machine name.

Listener restart fires on BindAddresses change but not on DisplayHost
change (cosmetic).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 09:27:18 -04:00

141 lines
5.0 KiB
C#

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 =>
{
ConfigureHttp(opts, initialConfig);
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();
};
// 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<WebhookRouter>();
await router.HandleAsync(http, slug);
});
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)
{
Log.Fatal(ex, "Service terminated unexpectedly");
}
finally
{
Log.CloseAndFlush();
}
static void ConfigureHttp(Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerOptions opts, ServerConfig cfg)
{
if (cfg.BindAddresses is { Count: > 0 } binds)
{
foreach (var entry in binds)
{
if (System.Net.IPAddress.TryParse(entry, out var ip))
opts.Listen(ip, cfg.HttpPort);
else
Log.Warning("Skipping invalid bind address {Entry}", entry);
}
}
else
{
opts.ListenAnyIP(cfg.HttpPort);
}
}
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));
}