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,297 @@
|
||||
using System.IO.Pipes;
|
||||
using System.Runtime.Versioning;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using WebhookServer.Core.Ipc;
|
||||
using WebhookServer.Core.Models;
|
||||
using WebhookServer.Core.Storage;
|
||||
|
||||
namespace WebhookServer.Service;
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
internal sealed class AdminPipeServer : BackgroundService
|
||||
{
|
||||
private readonly ServiceState _state;
|
||||
private readonly IHostApplicationLifetime _lifetime;
|
||||
private readonly ILogger<AdminPipeServer> _logger;
|
||||
|
||||
public AdminPipeServer(ServiceState state, IHostApplicationLifetime lifetime, ILogger<AdminPipeServer> logger)
|
||||
{
|
||||
_state = state;
|
||||
_lifetime = lifetime;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("Admin pipe server listening on \\\\.\\pipe\\{Pipe}", PipeSecurityFactory.PipeName);
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var pipe = NamedPipeServerStreamAcl.Create(
|
||||
PipeSecurityFactory.PipeName,
|
||||
PipeDirection.InOut,
|
||||
maxNumberOfServerInstances: 1,
|
||||
PipeTransmissionMode.Byte,
|
||||
PipeOptions.Asynchronous,
|
||||
inBufferSize: 0,
|
||||
outBufferSize: 0,
|
||||
PipeSecurityFactory.Create());
|
||||
|
||||
await pipe.WaitForConnectionAsync(stoppingToken).ConfigureAwait(false);
|
||||
await HandleClientAsync(pipe, stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Admin pipe accept loop error");
|
||||
try { await Task.Delay(500, stoppingToken).ConfigureAwait(false); }
|
||||
catch { break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleClientAsync(NamedPipeServerStream pipe, CancellationToken ct)
|
||||
{
|
||||
using var reader = PipeFraming.CreateReader(pipe);
|
||||
|
||||
while (pipe.IsConnected && !ct.IsCancellationRequested)
|
||||
{
|
||||
AdminRequest? request;
|
||||
try { request = await PipeFraming.ReadAsync<AdminRequest>(reader, ct).ConfigureAwait(false); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Admin pipe read error");
|
||||
break;
|
||||
}
|
||||
if (request is null) break;
|
||||
|
||||
AdminResponse response;
|
||||
try
|
||||
{
|
||||
response = await DispatchAsync(request, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Admin op {Op} failed", request.Op);
|
||||
response = AdminResponse.Failure(ex.Message);
|
||||
}
|
||||
|
||||
try { await PipeFraming.WriteAsync(pipe, response, ct).ConfigureAwait(false); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Admin pipe write error");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<AdminResponse> DispatchAsync(AdminRequest request, CancellationToken ct)
|
||||
{
|
||||
switch (request.Op)
|
||||
{
|
||||
case AdminOps.Ping:
|
||||
return AdminResponse.Success(new { pong = true, at = DateTimeOffset.UtcNow });
|
||||
|
||||
case AdminOps.GetStatus:
|
||||
{
|
||||
var snap = _state.Snapshot();
|
||||
return AdminResponse.Success(new StatusInfo
|
||||
{
|
||||
Running = true,
|
||||
HttpPort = snap.HttpPort,
|
||||
HttpsPort = snap.HttpsBinding?.Port,
|
||||
StartedAt = _state.StartedAt,
|
||||
EndpointCount = snap.Endpoints.Count,
|
||||
});
|
||||
}
|
||||
|
||||
case AdminOps.GetConfig:
|
||||
{
|
||||
var snap = SafeSnapshotForWire(_state.Snapshot());
|
||||
return AdminResponse.Success(snap);
|
||||
}
|
||||
|
||||
case AdminOps.UpdateConfig:
|
||||
{
|
||||
var incoming = DeserializeData<ServerConfig>(request) ?? throw new ArgumentException("missing config payload");
|
||||
MergeWithExistingSecrets(incoming, _state.Snapshot());
|
||||
await _state.ReplaceAsync(incoming, ct).ConfigureAwait(false);
|
||||
return AdminResponse.Success(SafeSnapshotForWire(_state.Snapshot()));
|
||||
}
|
||||
|
||||
case AdminOps.ListEndpoints:
|
||||
return AdminResponse.Success(SafeSnapshotForWire(_state.Snapshot()).Endpoints);
|
||||
|
||||
case AdminOps.CreateEndpoint:
|
||||
{
|
||||
var ep = DeserializeData<EndpointConfig>(request) ?? throw new ArgumentException("missing endpoint");
|
||||
if (ep.Id == Guid.Empty) ep.Id = Guid.NewGuid();
|
||||
var next = CloneSnapshotForEdit();
|
||||
if (next.Endpoints.Any(e => string.Equals(e.Slug, ep.Slug, StringComparison.Ordinal)))
|
||||
return AdminResponse.Failure($"slug '{ep.Slug}' already exists");
|
||||
next.Endpoints.Add(ep);
|
||||
await _state.ReplaceAsync(next, ct).ConfigureAwait(false);
|
||||
return AdminResponse.Success(ep);
|
||||
}
|
||||
|
||||
case AdminOps.UpdateEndpoint:
|
||||
{
|
||||
var ep = DeserializeData<EndpointConfig>(request) ?? throw new ArgumentException("missing endpoint");
|
||||
var next = CloneSnapshotForEdit();
|
||||
var idx = next.Endpoints.FindIndex(e => e.Id == ep.Id);
|
||||
if (idx < 0) return AdminResponse.Failure("endpoint not found");
|
||||
MergeEndpointSecrets(ep, next.Endpoints[idx]);
|
||||
next.Endpoints[idx] = ep;
|
||||
await _state.ReplaceAsync(next, ct).ConfigureAwait(false);
|
||||
return AdminResponse.Success(ep);
|
||||
}
|
||||
|
||||
case AdminOps.DeleteEndpoint:
|
||||
{
|
||||
var args = DeserializeData<DeleteEndpointArgs>(request) ?? throw new ArgumentException("missing id");
|
||||
var next = CloneSnapshotForEdit();
|
||||
var removed = next.Endpoints.RemoveAll(e => e.Id == args.Id);
|
||||
if (removed == 0) return AdminResponse.Failure("endpoint not found");
|
||||
await _state.ReplaceAsync(next, ct).ConfigureAwait(false);
|
||||
return AdminResponse.Success();
|
||||
}
|
||||
|
||||
case AdminOps.EnableEndpoint:
|
||||
case AdminOps.DisableEndpoint:
|
||||
{
|
||||
var args = DeserializeData<EndpointToggle>(request) ?? throw new ArgumentException("missing id");
|
||||
var next = CloneSnapshotForEdit();
|
||||
var ep = next.Endpoints.FirstOrDefault(e => e.Id == args.Id);
|
||||
if (ep is null) return AdminResponse.Failure("endpoint not found");
|
||||
ep.Enabled = request.Op == AdminOps.EnableEndpoint;
|
||||
await _state.ReplaceAsync(next, ct).ConfigureAwait(false);
|
||||
return AdminResponse.Success(ep);
|
||||
}
|
||||
|
||||
case AdminOps.BindHttps:
|
||||
{
|
||||
var binding = DeserializeData<HttpsBinding>(request);
|
||||
var next = CloneSnapshotForEdit();
|
||||
next.HttpsBinding = binding;
|
||||
await _state.ReplaceAsync(next, ct).ConfigureAwait(false);
|
||||
return AdminResponse.Success();
|
||||
}
|
||||
|
||||
case AdminOps.RestartListener:
|
||||
_logger.LogInformation("Restart requested via admin pipe");
|
||||
_lifetime.StopApplication();
|
||||
return AdminResponse.Success();
|
||||
|
||||
case AdminOps.TailLogs:
|
||||
{
|
||||
var args = DeserializeData<TailLogsArgs>(request) ?? new TailLogsArgs();
|
||||
var lines = ReadTailLines(args.LinesToBacklog);
|
||||
return AdminResponse.Success(new { lines });
|
||||
}
|
||||
|
||||
default:
|
||||
return AdminResponse.Failure($"unknown op '{request.Op}'");
|
||||
}
|
||||
}
|
||||
|
||||
private ServerConfig CloneSnapshotForEdit()
|
||||
{
|
||||
// Round-trip via JSON to avoid sharing references with the live snapshot.
|
||||
var snap = _state.Snapshot();
|
||||
var json = JsonSerializer.Serialize(snap, ConfigJson.Compact);
|
||||
return JsonSerializer.Deserialize<ServerConfig>(json, ConfigJson.Compact)!;
|
||||
}
|
||||
|
||||
private static T? DeserializeData<T>(AdminRequest request)
|
||||
{
|
||||
if (request.Data is not { ValueKind: not JsonValueKind.Null and not JsonValueKind.Undefined } element)
|
||||
return default;
|
||||
return element.Deserialize<T>(AdminProtocol.JsonOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Strip plaintext secrets from a snapshot before sending to the GUI. Encrypted
|
||||
/// blobs are useless to the GUI but harmless; plaintext must never leak.
|
||||
/// </summary>
|
||||
private static ServerConfig SafeSnapshotForWire(ServerConfig snap)
|
||||
{
|
||||
// Deep clone via JSON, then null out plaintext on the clone.
|
||||
var json = JsonSerializer.Serialize(snap, ConfigJson.Compact);
|
||||
var clone = JsonSerializer.Deserialize<ServerConfig>(json, ConfigJson.Compact)!;
|
||||
ConfigStore.ClearPlaintexts(clone);
|
||||
return clone;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When the GUI sends an <see cref="EndpointConfig"/> with empty plaintext on a
|
||||
/// secret, we keep the existing encrypted blob from disk. Without this, a GUI
|
||||
/// edit that doesn't touch the secret field would erase the secret.
|
||||
/// </summary>
|
||||
private static void MergeWithExistingSecrets(ServerConfig incoming, ServerConfig existing)
|
||||
{
|
||||
var byId = existing.Endpoints.ToDictionary(e => e.Id);
|
||||
foreach (var ep in incoming.Endpoints)
|
||||
{
|
||||
if (!byId.TryGetValue(ep.Id, out var prior)) continue;
|
||||
MergeEndpointSecrets(ep, prior);
|
||||
}
|
||||
|
||||
if (incoming.HttpsBinding is { } b && existing.HttpsBinding is { } prev)
|
||||
MergeProtected(b.PfxPassword, prev.PfxPassword);
|
||||
}
|
||||
|
||||
private static void MergeEndpointSecrets(EndpointConfig incoming, EndpointConfig prior)
|
||||
{
|
||||
if (incoming.Bearer is { } a) MergeProtected(a.Secret, prior.Bearer?.Secret);
|
||||
if (incoming.Hmac is { } h) MergeProtected(h.Secret, prior.Hmac?.Secret);
|
||||
if (incoming.Callback is { } cb)
|
||||
{
|
||||
if (cb.Bearer is { } cba) MergeProtected(cba.Secret, prior.Callback?.Bearer?.Secret);
|
||||
if (cb.Hmac is { } cbh) MergeProtected(cbh.Secret, prior.Callback?.Hmac?.Secret);
|
||||
}
|
||||
}
|
||||
|
||||
private static void MergeProtected(ProtectedString? incoming, ProtectedString? prior)
|
||||
{
|
||||
if (incoming is null) return;
|
||||
if (!string.IsNullOrEmpty(incoming.Plaintext)) return; // GUI is supplying a new value
|
||||
if (string.IsNullOrEmpty(incoming.Encrypted) && prior is not null && !string.IsNullOrEmpty(prior.Encrypted))
|
||||
incoming.Encrypted = prior.Encrypted; // preserve previous secret
|
||||
}
|
||||
|
||||
private static List<LogLine> ReadTailLines(int count)
|
||||
{
|
||||
try
|
||||
{
|
||||
var dir = ServicePaths.LogsDir;
|
||||
if (!Directory.Exists(dir)) return new List<LogLine>();
|
||||
var latest = Directory.GetFiles(dir, "webhook-*.log")
|
||||
.OrderByDescending(p => p)
|
||||
.FirstOrDefault();
|
||||
if (latest is null) return new List<LogLine>();
|
||||
|
||||
using var fs = new FileStream(latest, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
|
||||
using var sr = new StreamReader(fs);
|
||||
var lines = new LinkedList<string>();
|
||||
while (sr.ReadLine() is { } line)
|
||||
{
|
||||
lines.AddLast(line);
|
||||
if (lines.Count > count) lines.RemoveFirst();
|
||||
}
|
||||
return lines.Select(l => new LogLine
|
||||
{
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
Level = "Information",
|
||||
Message = l,
|
||||
}).ToList();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new List<LogLine>();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using WebhookServer.Core.Callbacks;
|
||||
|
||||
namespace WebhookServer.Service;
|
||||
|
||||
internal sealed class CallbackBackgroundService : BackgroundService
|
||||
{
|
||||
private readonly CallbackDispatcher _dispatcher;
|
||||
|
||||
public CallbackBackgroundService(CallbackDispatcher dispatcher) => _dispatcher = dispatcher;
|
||||
|
||||
protected override Task ExecuteAsync(CancellationToken stoppingToken) =>
|
||||
_dispatcher.RunAsync(stoppingToken);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace WebhookServer.Service;
|
||||
|
||||
/// <summary>
|
||||
/// Standard locations for runtime files (config + logs). Centralised so they're easy
|
||||
/// to override in tests and inspect in one place.
|
||||
/// </summary>
|
||||
public static class ServicePaths
|
||||
{
|
||||
public static string DataRoot { get; } =
|
||||
Environment.GetEnvironmentVariable("WEBHOOKSERVER_DATA")
|
||||
?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "WebhookServer");
|
||||
|
||||
public static string ConfigPath => Path.Combine(DataRoot, "config.json");
|
||||
public static string LogsDir => Path.Combine(DataRoot, "logs");
|
||||
public static string LogFileTemplate => Path.Combine(LogsDir, "webhook-.log");
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
using System.Runtime.Versioning;
|
||||
using WebhookServer.Core.Auth;
|
||||
using WebhookServer.Core.Models;
|
||||
using WebhookServer.Core.Storage;
|
||||
|
||||
namespace WebhookServer.Service;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory authoritative copy of the current <see cref="ServerConfig"/>. Holds parsed
|
||||
/// helpers (allowlists keyed by endpoint id, slug → endpoint map) and notifies subscribers
|
||||
/// when the config is replaced so the listener and dispatcher can react.
|
||||
/// </summary>
|
||||
[SupportedOSPlatform("windows")]
|
||||
public sealed class ServiceState
|
||||
{
|
||||
private readonly ConfigStore _store;
|
||||
private readonly object _lock = new();
|
||||
private ServerConfig _config = new();
|
||||
private Dictionary<string, EndpointConfig> _bySlug = new(StringComparer.Ordinal);
|
||||
private Dictionary<Guid, IpAllowList> _allowLists = new();
|
||||
private IpAllowList _trustedProxies = IpAllowList.Parse(Array.Empty<string>());
|
||||
|
||||
public DateTimeOffset StartedAt { get; } = DateTimeOffset.UtcNow;
|
||||
|
||||
public event EventHandler? ListenerSettingsChanged;
|
||||
|
||||
public ServiceState(ConfigStore store)
|
||||
{
|
||||
_store = store;
|
||||
}
|
||||
|
||||
public ServerConfig Snapshot()
|
||||
{
|
||||
lock (_lock) return _config;
|
||||
}
|
||||
|
||||
public bool TryGetEndpoint(string slug, out EndpointConfig endpoint)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _bySlug.TryGetValue(slug, out endpoint!);
|
||||
}
|
||||
}
|
||||
|
||||
public IpAllowList GetAllowList(Guid endpointId)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _allowLists.TryGetValue(endpointId, out var l) ? l : IpAllowList.Parse(Array.Empty<string>());
|
||||
}
|
||||
}
|
||||
|
||||
public IpAllowList GetTrustedProxies()
|
||||
{
|
||||
lock (_lock) return _trustedProxies;
|
||||
}
|
||||
|
||||
public async Task LoadAsync(CancellationToken ct = default)
|
||||
{
|
||||
var loaded = await _store.LoadAsync(ct).ConfigureAwait(false);
|
||||
ConfigStore.DecryptSecrets(loaded);
|
||||
Replace(loaded, listenerChanged: true);
|
||||
}
|
||||
|
||||
public async Task ReplaceAsync(ServerConfig replacement, CancellationToken ct = default)
|
||||
{
|
||||
// Save to disk first; that re-encrypts secrets in place. Then publish in-memory.
|
||||
var listenerChanged = HasListenerSettingsChanged(_config, replacement);
|
||||
await _store.SaveAsync(replacement, ct).ConfigureAwait(false);
|
||||
|
||||
// SaveAsync filled in Encrypted; ensure Plaintext is populated for runtime use.
|
||||
ConfigStore.DecryptSecrets(replacement);
|
||||
Replace(replacement, listenerChanged);
|
||||
}
|
||||
|
||||
private void Replace(ServerConfig cfg, bool listenerChanged)
|
||||
{
|
||||
var bySlug = new Dictionary<string, EndpointConfig>(StringComparer.Ordinal);
|
||||
var allow = new Dictionary<Guid, IpAllowList>();
|
||||
|
||||
foreach (var ep in cfg.Endpoints)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(ep.Slug))
|
||||
bySlug[ep.Slug] = ep;
|
||||
allow[ep.Id] = IpAllowList.Parse(ep.AllowedClients);
|
||||
}
|
||||
|
||||
var trusted = IpAllowList.Parse(cfg.TrustedProxies);
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
_config = cfg;
|
||||
_bySlug = bySlug;
|
||||
_allowLists = allow;
|
||||
_trustedProxies = trusted;
|
||||
}
|
||||
|
||||
if (listenerChanged)
|
||||
ListenerSettingsChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
private static bool HasListenerSettingsChanged(ServerConfig oldCfg, ServerConfig newCfg)
|
||||
{
|
||||
if (oldCfg.HttpPort != newCfg.HttpPort) return true;
|
||||
var a = oldCfg.HttpsBinding;
|
||||
var b = newCfg.HttpsBinding;
|
||||
if ((a is null) != (b is null)) return true;
|
||||
if (a is not null && b is not null)
|
||||
{
|
||||
if (a.Kind != b.Kind || a.Port != b.Port || a.PfxPath != b.PfxPath || a.Thumbprint != b.Thumbprint)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Runtime.Versioning;
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using WebhookServer.Core.Auth;
|
||||
using WebhookServer.Core.Callbacks;
|
||||
using WebhookServer.Core.Execution;
|
||||
using WebhookServer.Core.Models;
|
||||
using ExecCtx = WebhookServer.Core.Execution.ExecutionContext;
|
||||
|
||||
namespace WebhookServer.Service;
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
public sealed class WebhookRouter
|
||||
{
|
||||
private readonly ServiceState _state;
|
||||
private readonly IExecutor _executor;
|
||||
private readonly ConcurrencyGate _gate;
|
||||
private readonly CallbackDispatcher _callbacks;
|
||||
private readonly ILogger<WebhookRouter> _logger;
|
||||
|
||||
public WebhookRouter(
|
||||
ServiceState state,
|
||||
IExecutor executor,
|
||||
ConcurrencyGate gate,
|
||||
CallbackDispatcher callbacks,
|
||||
ILogger<WebhookRouter> logger)
|
||||
{
|
||||
_state = state;
|
||||
_executor = executor;
|
||||
_gate = gate;
|
||||
_callbacks = callbacks;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task HandleAsync(HttpContext http, string slug)
|
||||
{
|
||||
var runId = Guid.NewGuid().ToString("N");
|
||||
|
||||
if (!_state.TryGetEndpoint(slug, out var endpoint) || !endpoint.Enabled)
|
||||
{
|
||||
http.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
return;
|
||||
}
|
||||
|
||||
var clientIp = ResolveClientIp(http);
|
||||
|
||||
// 1. IP allowlist (before auth, before reading body).
|
||||
var allowList = _state.GetAllowList(endpoint.Id);
|
||||
if (!allowList.IsEmpty && (clientIp is null || !allowList.Contains(clientIp)))
|
||||
{
|
||||
_logger.LogWarning("IP {Ip} blocked for endpoint {Slug} (run {RunId})", clientIp, slug, runId);
|
||||
http.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Capture raw body bytes (needed for HMAC verification and stdin/template).
|
||||
byte[] bodyBytes;
|
||||
try
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
await http.Request.Body.CopyToAsync(ms, http.RequestAborted).ConfigureAwait(false);
|
||||
bodyBytes = ms.ToArray();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed reading body for {Slug} (run {RunId})", slug, runId);
|
||||
http.Response.StatusCode = StatusCodes.Status400BadRequest;
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Auth.
|
||||
var authResult = VerifyAuth(endpoint, http, bodyBytes);
|
||||
if (!authResult.Success)
|
||||
{
|
||||
_logger.LogWarning("Auth failed for {Slug}: {Reason} (run {RunId})", slug, authResult.Reason, runId);
|
||||
http.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. Build execution context.
|
||||
var bodyString = Encoding.UTF8.GetString(bodyBytes);
|
||||
JsonNode? bodyJson = null;
|
||||
try
|
||||
{
|
||||
if (bodyBytes.Length > 0)
|
||||
bodyJson = JsonNode.Parse(bodyBytes);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Non-JSON body — leave bodyJson null so {{body.*}} renders empty.
|
||||
}
|
||||
|
||||
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var (key, value) in http.Request.Headers)
|
||||
headers[key] = value.ToString();
|
||||
|
||||
var query = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var (key, value) in http.Request.Query)
|
||||
query[key] = value.ToString();
|
||||
|
||||
var route = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) { { "slug", slug } };
|
||||
|
||||
var ctx = new ExecCtx
|
||||
{
|
||||
RunId = runId,
|
||||
Slug = slug,
|
||||
BodyBytes = bodyBytes,
|
||||
BodyString = bodyString,
|
||||
BodyJson = bodyJson,
|
||||
Headers = headers,
|
||||
Query = query,
|
||||
Route = route,
|
||||
};
|
||||
|
||||
// 5. Dispatch.
|
||||
if (endpoint.ResponseMode == ResponseMode.Async)
|
||||
{
|
||||
_ = Task.Run(() => RunAndDispatchCallbackAsync(endpoint, ctx, http.RequestAborted));
|
||||
http.Response.StatusCode = StatusCodes.Status202Accepted;
|
||||
await WriteJsonAsync(http, new { runId, accepted = true }).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var result = await RunAsync(endpoint, ctx, http.RequestAborted).ConfigureAwait(false);
|
||||
DispatchCallback(endpoint, ctx, result);
|
||||
|
||||
if (result.LaunchError is not null)
|
||||
{
|
||||
http.Response.StatusCode = StatusCodes.Status500InternalServerError;
|
||||
await WriteJsonAsync(http, new { runId, error = result.LaunchError }).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
http.Response.StatusCode = endpoint.FailOnNonZeroExit && !result.Succeeded
|
||||
? StatusCodes.Status502BadGateway
|
||||
: StatusCodes.Status200OK;
|
||||
|
||||
await WriteJsonAsync(http, new
|
||||
{
|
||||
runId,
|
||||
exitCode = result.ExitCode,
|
||||
timedOut = result.TimedOut,
|
||||
durationMs = (long)result.Duration.TotalMilliseconds,
|
||||
stdout = result.Stdout,
|
||||
stderr = result.Stderr,
|
||||
stdoutTruncated = result.StdoutTruncated,
|
||||
stderrTruncated = result.StderrTruncated,
|
||||
}).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task RunAndDispatchCallbackAsync(EndpointConfig endpoint, ExecCtx ctx, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await RunAsync(endpoint, ctx, ct).ConfigureAwait(false);
|
||||
DispatchCallback(endpoint, ctx, result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Async run failed for {Slug} (run {RunId})", ctx.Slug, ctx.RunId);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ExecutionResult> RunAsync(EndpointConfig endpoint, ExecCtx ctx, CancellationToken ct)
|
||||
{
|
||||
if (endpoint.Serialize)
|
||||
{
|
||||
using var _ = await _gate.AcquireAsync(endpoint.Id, ct).ConfigureAwait(false);
|
||||
return await _executor.RunAsync(endpoint, ctx, ct).ConfigureAwait(false);
|
||||
}
|
||||
return await _executor.RunAsync(endpoint, ctx, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private void DispatchCallback(EndpointConfig endpoint, ExecCtx ctx, ExecutionResult result)
|
||||
{
|
||||
var cb = endpoint.Callback;
|
||||
if (cb is null || string.IsNullOrEmpty(cb.Url)) return;
|
||||
|
||||
var trigger = cb.Trigger;
|
||||
var fire = trigger switch
|
||||
{
|
||||
CallbackTrigger.OnSuccess => result.Succeeded,
|
||||
CallbackTrigger.OnFailure => !result.Succeeded,
|
||||
_ => true,
|
||||
};
|
||||
if (!fire) return;
|
||||
|
||||
var stdout = TruncateBytes(result.Stdout, cb.MaxOutputBytes, out var stdoutCut);
|
||||
var stderr = TruncateBytes(result.Stderr, cb.MaxOutputBytes, out var stderrCut);
|
||||
|
||||
var payload = new CallbackPayload
|
||||
{
|
||||
RunId = ctx.RunId,
|
||||
Endpoint = ctx.Slug,
|
||||
StartedAt = result.StartedAt,
|
||||
CompletedAt = result.CompletedAt,
|
||||
DurationMs = (long)result.Duration.TotalMilliseconds,
|
||||
ExitCode = result.ExitCode,
|
||||
Succeeded = result.Succeeded,
|
||||
TimedOut = result.TimedOut,
|
||||
Stdout = stdout,
|
||||
Stderr = stderr,
|
||||
StdoutTruncated = result.StdoutTruncated || stdoutCut,
|
||||
StderrTruncated = result.StderrTruncated || stderrCut,
|
||||
};
|
||||
|
||||
_callbacks.Enqueue(new CallbackEnvelope
|
||||
{
|
||||
EndpointId = endpoint.Id,
|
||||
EndpointSlug = ctx.Slug,
|
||||
Config = cb,
|
||||
Payload = payload,
|
||||
});
|
||||
}
|
||||
|
||||
private static string TruncateBytes(string s, int maxBytes, out bool truncated)
|
||||
{
|
||||
truncated = false;
|
||||
if (string.IsNullOrEmpty(s)) return s;
|
||||
if (maxBytes <= 0) { truncated = true; return ""; }
|
||||
var bytes = Encoding.UTF8.GetByteCount(s);
|
||||
if (bytes <= maxBytes) return s;
|
||||
|
||||
// Trim from the end until under cap. Cheap and good enough.
|
||||
var bs = Encoding.UTF8.GetBytes(s);
|
||||
truncated = true;
|
||||
return Encoding.UTF8.GetString(bs.AsSpan(0, maxBytes));
|
||||
}
|
||||
|
||||
private static async Task WriteJsonAsync(HttpContext http, object payload)
|
||||
{
|
||||
http.Response.ContentType = "application/json; charset=utf-8";
|
||||
await System.Text.Json.JsonSerializer.SerializeAsync(http.Response.Body, payload, options: new() { PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase }, http.RequestAborted).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private AuthResult VerifyAuth(EndpointConfig endpoint, HttpContext http, byte[] body)
|
||||
{
|
||||
switch (endpoint.AuthMode)
|
||||
{
|
||||
case AuthMode.None:
|
||||
return AuthResult.Ok();
|
||||
case AuthMode.Bearer:
|
||||
var token = endpoint.Bearer?.Secret.Plaintext ?? "";
|
||||
return BearerVerifier.Verify(http.Request.Headers.Authorization.ToString(), token);
|
||||
case AuthMode.Hmac:
|
||||
if (endpoint.Hmac is null) return AuthResult.Fail("HMAC config missing");
|
||||
var headerName = endpoint.Hmac.HeaderName;
|
||||
var presented = http.Request.Headers.TryGetValue(headerName, out var v) ? v.ToString() : null;
|
||||
return HmacVerifier.Verify(body, presented, endpoint.Hmac);
|
||||
default:
|
||||
return AuthResult.Fail("unknown auth mode");
|
||||
}
|
||||
}
|
||||
|
||||
private IPAddress? ResolveClientIp(HttpContext http)
|
||||
{
|
||||
var direct = http.Connection.RemoteIpAddress;
|
||||
if (direct is null) return null;
|
||||
|
||||
var trustedProxies = _state.GetTrustedProxies();
|
||||
if (trustedProxies.IsEmpty || !trustedProxies.Contains(direct))
|
||||
return Normalize(direct);
|
||||
|
||||
// Direct hop is a trusted proxy — honor X-Forwarded-For (leftmost).
|
||||
if (http.Request.Headers.TryGetValue("X-Forwarded-For", out var xff) && !string.IsNullOrEmpty(xff))
|
||||
{
|
||||
var first = xff.ToString().Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).FirstOrDefault();
|
||||
if (!string.IsNullOrEmpty(first) && IPAddress.TryParse(first, out var parsed))
|
||||
return Normalize(parsed);
|
||||
}
|
||||
|
||||
return Normalize(direct);
|
||||
}
|
||||
|
||||
private static IPAddress Normalize(IPAddress address)
|
||||
{
|
||||
if (address.AddressFamily == AddressFamily.InterNetworkV6 && address.IsIPv4MappedToIPv6)
|
||||
return address.MapToIPv4();
|
||||
return address;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UserSecretsId>dotnet-WebhookServer.Service-57f4579b-6131-4fab-a6ad-2865b038cc2e</UserSecretsId>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="8.0.1" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="8.0.3" />
|
||||
<PackageReference Include="Serilog.Sinks.Async" Version="2.1.0" />
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\WebhookServer.Core\WebhookServer.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.Hosting.Lifetime": "Information"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user