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
@@ -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>();
}
}
}