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,7 @@
namespace WebhookServer.Core.Auth;
public readonly record struct AuthResult(bool Success, string? Reason)
{
public static AuthResult Ok() => new(true, null);
public static AuthResult Fail(string reason) => new(false, reason);
}
@@ -0,0 +1,32 @@
using System.Security.Cryptography;
using System.Text;
namespace WebhookServer.Core.Auth;
public static class BearerVerifier
{
private const string Prefix = "Bearer ";
/// <summary>
/// Compares the value of an Authorization header against an expected secret in fixed time.
/// </summary>
public static AuthResult Verify(string? authorizationHeader, string expectedSecret)
{
if (string.IsNullOrEmpty(expectedSecret))
return AuthResult.Fail("server secret not configured");
if (string.IsNullOrEmpty(authorizationHeader))
return AuthResult.Fail("missing Authorization header");
if (!authorizationHeader.StartsWith(Prefix, StringComparison.Ordinal))
return AuthResult.Fail("Authorization header is not a Bearer token");
var presented = authorizationHeader.AsSpan(Prefix.Length).Trim();
var presentedBytes = Encoding.UTF8.GetBytes(presented.ToString());
var expectedBytes = Encoding.UTF8.GetBytes(expectedSecret);
return CryptographicOperations.FixedTimeEquals(presentedBytes, expectedBytes)
? AuthResult.Ok()
: AuthResult.Fail("bearer token mismatch");
}
}
@@ -0,0 +1,76 @@
using System.Security.Cryptography;
using System.Text;
using WebhookServer.Core.Models;
namespace WebhookServer.Core.Auth;
public static class HmacVerifier
{
/// <summary>
/// Compute the signature string (encoded per <paramref name="encoding"/>, no prefix)
/// for the given body bytes and shared secret.
/// </summary>
public static string Compute(
ReadOnlySpan<byte> body,
string secret,
HmacAlgorithm algorithm,
HmacEncoding encoding)
{
var keyBytes = Encoding.UTF8.GetBytes(secret);
Span<byte> hash = stackalloc byte[64]; // SHA-512 is 64 bytes max
int written = algorithm switch
{
HmacAlgorithm.Sha1 => HMACSHA1.HashData(keyBytes, body, hash),
HmacAlgorithm.Sha256 => HMACSHA256.HashData(keyBytes, body, hash),
HmacAlgorithm.Sha512 => HMACSHA512.HashData(keyBytes, body, hash),
_ => throw new ArgumentOutOfRangeException(nameof(algorithm)),
};
var hashBytes = hash[..written];
return encoding switch
{
HmacEncoding.Hex => Convert.ToHexString(hashBytes).ToLowerInvariant(),
HmacEncoding.Base64 => Convert.ToBase64String(hashBytes),
_ => throw new ArgumentOutOfRangeException(nameof(encoding)),
};
}
/// <summary>
/// Verify the HMAC signature in <paramref name="presentedHeaderValue"/> against the
/// computed signature for <paramref name="body"/>. Strips the configured prefix
/// before comparing. Comparison is constant time.
/// </summary>
public static AuthResult Verify(
ReadOnlySpan<byte> body,
string? presentedHeaderValue,
HmacOptions options)
{
if (options.Secret.Plaintext is not { Length: > 0 } secret)
return AuthResult.Fail("HMAC secret not available");
if (string.IsNullOrEmpty(presentedHeaderValue))
return AuthResult.Fail($"missing {options.HeaderName} header");
var presented = presentedHeaderValue.AsSpan().Trim();
if (!string.IsNullOrEmpty(options.Prefix))
{
if (!presented.StartsWith(options.Prefix, StringComparison.OrdinalIgnoreCase))
return AuthResult.Fail("signature prefix mismatch");
presented = presented[options.Prefix.Length..];
}
var expected = Compute(body, secret, options.Algorithm, options.Encoding);
// Encoding for hex is case-insensitive in practice; normalize to lower.
var presentedNormalized = options.Encoding == HmacEncoding.Hex
? presented.ToString().ToLowerInvariant()
: presented.ToString();
var presentedBytes = Encoding.ASCII.GetBytes(presentedNormalized);
var expectedBytes = Encoding.ASCII.GetBytes(expected);
return CryptographicOperations.FixedTimeEquals(presentedBytes, expectedBytes)
? AuthResult.Ok()
: AuthResult.Fail("HMAC signature mismatch");
}
}
@@ -0,0 +1,87 @@
using System.Net;
using System.Net.Sockets;
namespace WebhookServer.Core.Auth;
/// <summary>
/// Compiled allow-list of IPs and CIDR ranges. Empty list = allow all.
/// </summary>
public sealed class IpAllowList
{
private readonly List<IPNetwork> _networks;
public bool IsEmpty => _networks.Count == 0;
private IpAllowList(List<IPNetwork> networks) => _networks = networks;
public bool Contains(IPAddress address)
{
if (IsEmpty) return true;
var normalized = Normalize(address);
foreach (var net in _networks)
{
if (net.BaseAddress.AddressFamily != normalized.AddressFamily) continue;
if (net.Contains(normalized)) return true;
}
return false;
}
/// <summary>
/// Parse a list of allowlist entries. Each entry may be a single IP or a CIDR.
/// Throws <see cref="FormatException"/> on the first invalid entry.
/// </summary>
public static IpAllowList Parse(IEnumerable<string> entries)
{
var nets = new List<IPNetwork>();
foreach (var raw in entries)
{
var entry = raw?.Trim();
if (string.IsNullOrEmpty(entry)) continue;
nets.Add(ParseEntry(entry));
}
return new IpAllowList(nets);
}
public static bool TryParse(IEnumerable<string> entries, out IpAllowList list, out string? error)
{
var nets = new List<IPNetwork>();
foreach (var raw in entries)
{
var entry = raw?.Trim();
if (string.IsNullOrEmpty(entry)) continue;
try
{
nets.Add(ParseEntry(entry));
}
catch (FormatException ex)
{
list = new IpAllowList(new List<IPNetwork>());
error = $"invalid entry '{raw}': {ex.Message}";
return false;
}
}
list = new IpAllowList(nets);
error = null;
return true;
}
private static IPNetwork ParseEntry(string entry)
{
if (entry.Contains('/'))
return IPNetwork.Parse(entry);
if (!IPAddress.TryParse(entry, out var addr))
throw new FormatException($"'{entry}' is not a valid IP address or CIDR");
var prefix = addr.AddressFamily == AddressFamily.InterNetworkV6 ? 128 : 32;
return new IPNetwork(Normalize(addr), prefix);
}
private static IPAddress Normalize(IPAddress address)
{
if (address.AddressFamily == AddressFamily.InterNetworkV6 && address.IsIPv4MappedToIPv6)
return address.MapToIPv4();
return address;
}
}
@@ -0,0 +1,219 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Threading.Channels;
using Microsoft.Extensions.Logging;
using WebhookServer.Core.Auth;
using WebhookServer.Core.Models;
using WebhookServer.Core.Storage;
namespace WebhookServer.Core.Callbacks;
/// <summary>
/// Bounded queue of pending callback deliveries with retry + backoff. Reuses
/// <see cref="HmacVerifier.Compute"/> so outbound HMAC matches the inbound code path.
///
/// Run <see cref="RunAsync"/> from a single long-running task (BackgroundService in the
/// service host); call <see cref="Enqueue"/> from anywhere. Disposing the dispatcher
/// disposes its <see cref="HttpClient"/>.
/// </summary>
public sealed class CallbackDispatcher : IDisposable
{
private const int QueueCapacity = 1024;
private static readonly TimeSpan MaxRetryAfter = TimeSpan.FromSeconds(60);
private readonly Channel<CallbackEnvelope> _channel;
private readonly HttpClient _http;
private readonly ILogger<CallbackDispatcher>? _logger;
public CallbackDispatcher(ILogger<CallbackDispatcher>? logger = null, HttpClient? httpClient = null)
{
_logger = logger;
_channel = Channel.CreateBounded<CallbackEnvelope>(new BoundedChannelOptions(QueueCapacity)
{
FullMode = BoundedChannelFullMode.DropOldest,
SingleReader = true,
SingleWriter = false,
});
_http = httpClient ?? new HttpClient(new SocketsHttpHandler
{
AllowAutoRedirect = true,
MaxAutomaticRedirections = 3,
});
}
public bool Enqueue(CallbackEnvelope envelope)
{
var ok = _channel.Writer.TryWrite(envelope);
if (!ok)
{
_logger?.LogWarning("Callback queue full; dropped envelope for endpoint {Slug}", envelope.EndpointSlug);
}
return ok;
}
public async Task RunAsync(CancellationToken stoppingToken)
{
await foreach (var envelope in _channel.Reader.ReadAllAsync(stoppingToken).ConfigureAwait(false))
{
try
{
await DeliverAsync(envelope, stoppingToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger?.LogError(ex, "Unhandled error in callback dispatcher for {Slug}", envelope.EndpointSlug);
}
}
}
private async Task DeliverAsync(CallbackEnvelope envelope, CancellationToken stoppingToken)
{
var cfg = envelope.Config;
var maxAttempts = Math.Max(1, cfg.MaxAttempts);
var bodyBytes = SerializePayload(envelope.Payload, cfg);
for (int attempt = 1; attempt <= maxAttempts; attempt++)
{
using var attemptCts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
attemptCts.CancelAfter(TimeSpan.FromSeconds(Math.Max(1, cfg.TimeoutSeconds)));
var sw = System.Diagnostics.Stopwatch.StartNew();
HttpResponseMessage? response = null;
string? errorReason = null;
try
{
using var request = BuildRequest(envelope, bodyBytes);
response = await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, attemptCts.Token).ConfigureAwait(false);
}
catch (TaskCanceledException) when (attemptCts.IsCancellationRequested && !stoppingToken.IsCancellationRequested)
{
errorReason = "timeout";
}
catch (Exception ex)
{
errorReason = ex.GetType().Name;
}
sw.Stop();
int? statusCode = (int?)response?.StatusCode;
bool delivered = response is { IsSuccessStatusCode: true };
_logger?.LogInformation(
"Callback {Slug} attempt {Attempt}/{Max} -> {Status} ({Latency} ms){Error}",
envelope.EndpointSlug, attempt, maxAttempts,
statusCode?.ToString() ?? "ERR",
sw.ElapsedMilliseconds,
errorReason is null ? "" : $" [{errorReason}]");
if (delivered)
{
response?.Dispose();
return;
}
var transient = errorReason is not null || (statusCode.HasValue && IsRetryable(statusCode.Value));
if (!transient || attempt == maxAttempts)
{
_logger?.LogWarning("Callback {Slug} {Disposition} after {Attempts} attempts",
envelope.EndpointSlug,
transient ? "gave-up" : "dropped",
attempt);
response?.Dispose();
return;
}
var delay = ComputeBackoff(attempt, response);
response?.Dispose();
try { await Task.Delay(delay, stoppingToken).ConfigureAwait(false); }
catch (OperationCanceledException) { return; }
}
}
private HttpRequestMessage BuildRequest(CallbackEnvelope envelope, byte[] bodyBytes)
{
var cfg = envelope.Config;
var method = cfg.Method == CallbackHttpMethod.Put ? HttpMethod.Put : HttpMethod.Post;
var request = new HttpRequestMessage(method, cfg.Url)
{
Content = new ByteArrayContent(bodyBytes),
};
request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json") { CharSet = "utf-8" };
switch (cfg.AuthMode)
{
case AuthMode.Bearer:
if (cfg.Bearer?.Secret.Plaintext is { Length: > 0 } token)
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
break;
case AuthMode.Hmac:
if (cfg.Hmac is { } hmac && hmac.Secret.Plaintext is { Length: > 0 } secret)
{
var sig = HmacVerifier.Compute(bodyBytes, secret, hmac.Algorithm, hmac.Encoding);
request.Headers.TryAddWithoutValidation(hmac.HeaderName, hmac.Prefix + sig);
}
break;
}
return request;
}
private static byte[] SerializePayload(CallbackPayload payload, CallbackConfig cfg)
{
// Honor the IncludeStdout / IncludeStderr flags by wiping them out before serialization.
var effective = new CallbackPayload
{
RunId = payload.RunId,
Endpoint = payload.Endpoint,
StartedAt = payload.StartedAt,
CompletedAt = payload.CompletedAt,
DurationMs = payload.DurationMs,
ExitCode = payload.ExitCode,
Succeeded = payload.Succeeded,
TimedOut = payload.TimedOut,
Stdout = cfg.IncludeStdout ? payload.Stdout : null,
Stderr = cfg.IncludeStderr ? payload.Stderr : null,
StdoutTruncated = cfg.IncludeStdout && payload.StdoutTruncated,
StderrTruncated = cfg.IncludeStderr && payload.StderrTruncated,
};
return JsonSerializer.SerializeToUtf8Bytes(effective, ConfigJson.Compact);
}
private static bool IsRetryable(int status) => status switch
{
408 or 425 or 429 => true,
>= 500 and <= 599 => true,
_ => false,
};
private static TimeSpan ComputeBackoff(int attempt, HttpResponseMessage? response)
{
if (response?.Headers.RetryAfter is { } ra)
{
if (ra.Delta.HasValue)
return Min(ra.Delta.Value, MaxRetryAfter);
if (ra.Date.HasValue)
{
var delta = ra.Date.Value - DateTimeOffset.UtcNow;
if (delta > TimeSpan.Zero) return Min(delta, MaxRetryAfter);
}
}
// Exponential: 1s, 2s, 4s, 8s, 16s, 32s, 60s cap
var seconds = Math.Min(60, Math.Pow(2, attempt - 1));
var jitter = (Random.Shared.NextDouble() * 0.5) - 0.25; // ±25%
return TimeSpan.FromSeconds(seconds * (1 + jitter));
}
private static TimeSpan Min(TimeSpan a, TimeSpan b) => a < b ? a : b;
public void Dispose() => _http.Dispose();
}
@@ -0,0 +1,15 @@
using WebhookServer.Core.Models;
namespace WebhookServer.Core.Callbacks;
/// <summary>
/// Internal queue item pairing a payload with the resolved <see cref="CallbackConfig"/>
/// for the endpoint. The dispatcher reads from a channel of these.
/// </summary>
public sealed class CallbackEnvelope
{
public required Guid EndpointId { get; init; }
public required string EndpointSlug { get; init; }
public required CallbackConfig Config { get; init; }
public required CallbackPayload Payload { get; init; }
}
@@ -0,0 +1,22 @@
using System.Text.Json.Serialization;
namespace WebhookServer.Core.Callbacks;
/// <summary>
/// JSON body POSTed to a configured outbound callback URL.
/// </summary>
public sealed class CallbackPayload
{
[JsonPropertyName("runId")] public required string RunId { get; init; }
[JsonPropertyName("endpoint")] public required string Endpoint { get; init; }
[JsonPropertyName("startedAt")] public required DateTimeOffset StartedAt { get; init; }
[JsonPropertyName("completedAt")] public required DateTimeOffset CompletedAt { get; init; }
[JsonPropertyName("durationMs")] public required long DurationMs { get; init; }
[JsonPropertyName("exitCode")] public required int ExitCode { get; init; }
[JsonPropertyName("succeeded")] public required bool Succeeded { get; init; }
[JsonPropertyName("timedOut")] public required bool TimedOut { get; init; }
[JsonPropertyName("stdout")] public string? Stdout { get; init; }
[JsonPropertyName("stderr")] public string? Stderr { get; init; }
[JsonPropertyName("stdoutTruncated")] public bool StdoutTruncated { get; init; }
[JsonPropertyName("stderrTruncated")] public bool StderrTruncated { get; init; }
}
@@ -0,0 +1,116 @@
using System.Text.Json.Nodes;
namespace WebhookServer.Core.Execution;
/// <summary>
/// Resolves {{path}} tokens against an <see cref="ExecutionContext"/>. Each whitespace-
/// separated token in the template becomes one argv entry.
/// Path grammar:
/// {{body.foo.bar}} JSON path into the body
/// {{header.X-Foo}} header by name (case-insensitive)
/// {{query.bar}} query param
/// {{route.slug}} route value
/// Missing paths render as empty string.
/// </summary>
public static class ArgTemplateRenderer
{
public static List<string> Render(string? template, ExecutionContext ctx)
{
var args = new List<string>();
if (string.IsNullOrWhiteSpace(template)) return args;
foreach (var token in template.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
args.Add(RenderToken(token, ctx));
}
return args;
}
private static string RenderToken(string token, ExecutionContext ctx)
{
// Replace every {{...}} occurrence inside the token in a single left-to-right pass.
var result = new System.Text.StringBuilder(token.Length);
var i = 0;
while (i < token.Length)
{
var open = token.IndexOf("{{", i, StringComparison.Ordinal);
if (open < 0)
{
result.Append(token, i, token.Length - i);
break;
}
result.Append(token, i, open - i);
var close = token.IndexOf("}}", open + 2, StringComparison.Ordinal);
if (close < 0)
{
// Unclosed token — treat the rest as literal.
result.Append(token, open, token.Length - open);
break;
}
var path = token.Substring(open + 2, close - (open + 2)).Trim();
result.Append(Resolve(path, ctx));
i = close + 2;
}
return result.ToString();
}
private static string Resolve(string path, ExecutionContext ctx)
{
if (string.IsNullOrEmpty(path)) return "";
var dot = path.IndexOf('.');
if (dot < 0) return "";
var scope = path[..dot];
var rest = path[(dot + 1)..];
return scope.ToLowerInvariant() switch
{
"body" => ResolveJson(ctx.BodyJson, rest),
"header" => LookupCaseInsensitive(ctx.Headers, rest),
"query" => LookupCaseInsensitive(ctx.Query, rest),
"route" => LookupCaseInsensitive(ctx.Route, rest),
_ => "",
};
}
private static string ResolveJson(JsonNode? root, string path)
{
if (root is null) return "";
JsonNode? cursor = root;
foreach (var segment in path.Split('.'))
{
if (cursor is null) return "";
if (cursor is JsonObject obj)
{
cursor = obj.TryGetPropertyValue(segment, out var next) ? next : null;
continue;
}
if (cursor is JsonArray arr && int.TryParse(segment, out var idx))
{
cursor = idx >= 0 && idx < arr.Count ? arr[idx] : null;
continue;
}
return "";
}
return cursor switch
{
null => "",
JsonValue v => v.ToString(),
_ => cursor.ToJsonString(),
};
}
private static string LookupCaseInsensitive(IReadOnlyDictionary<string, string> map, string key)
{
foreach (var kvp in map)
{
if (string.Equals(kvp.Key, key, StringComparison.OrdinalIgnoreCase))
return kvp.Value;
}
return "";
}
}
@@ -0,0 +1,37 @@
using System.Collections.Concurrent;
namespace WebhookServer.Core.Execution;
/// <summary>
/// Holds one <see cref="SemaphoreSlim"/> per endpoint. When an endpoint is configured
/// with Serialize=true, the executor must acquire its semaphore before running and
/// release after — guaranteeing at-most-one concurrent run per endpoint.
/// </summary>
public sealed class ConcurrencyGate
{
private readonly ConcurrentDictionary<Guid, SemaphoreSlim> _gates = new();
public async Task<IDisposable> AcquireAsync(Guid endpointId, CancellationToken ct)
{
var sem = _gates.GetOrAdd(endpointId, _ => new SemaphoreSlim(1, 1));
await sem.WaitAsync(ct).ConfigureAwait(false);
return new Releaser(sem);
}
public void Forget(Guid endpointId)
{
if (_gates.TryRemove(endpointId, out var sem))
sem.Dispose();
}
private sealed class Releaser : IDisposable
{
private SemaphoreSlim? _sem;
public Releaser(SemaphoreSlim sem) => _sem = sem;
public void Dispose()
{
var sem = Interlocked.Exchange(ref _sem, null);
sem?.Release();
}
}
}
@@ -0,0 +1,18 @@
using System.Text.Json.Nodes;
namespace WebhookServer.Core.Execution;
/// <summary>
/// All data the executor needs from the inbound HTTP request.
/// </summary>
public sealed class ExecutionContext
{
public required string RunId { get; init; }
public required string Slug { get; init; }
public required byte[] BodyBytes { get; init; }
public required string BodyString { get; init; }
public JsonNode? BodyJson { get; init; }
public required IReadOnlyDictionary<string, string> Headers { get; init; }
public required IReadOnlyDictionary<string, string> Query { get; init; }
public required IReadOnlyDictionary<string, string> Route { get; init; }
}
@@ -0,0 +1,18 @@
namespace WebhookServer.Core.Execution;
public sealed class ExecutionResult
{
public required string RunId { get; init; }
public required int ExitCode { get; init; }
public required string Stdout { get; init; }
public required string Stderr { get; init; }
public bool StdoutTruncated { get; init; }
public bool StderrTruncated { get; init; }
public required DateTimeOffset StartedAt { get; init; }
public required DateTimeOffset CompletedAt { get; init; }
public required bool TimedOut { get; init; }
public string? LaunchError { get; init; }
public TimeSpan Duration => CompletedAt - StartedAt;
public bool Succeeded => !TimedOut && LaunchError is null && ExitCode == 0;
}
@@ -0,0 +1,8 @@
using WebhookServer.Core.Models;
namespace WebhookServer.Core.Execution;
public interface IExecutor
{
Task<ExecutionResult> RunAsync(EndpointConfig endpoint, ExecutionContext ctx, CancellationToken ct);
}
@@ -0,0 +1,234 @@
using System.Diagnostics;
using System.Text;
using WebhookServer.Core.Models;
namespace WebhookServer.Core.Execution;
public sealed class ProcessExecutor : IExecutor
{
/// <summary>Per-stream cap on captured output (excess is dropped and StdoutTruncated set).</summary>
public const int MaxOutputBytes = 1 * 1024 * 1024;
public async Task<ExecutionResult> RunAsync(EndpointConfig endpoint, ExecutionContext ctx, CancellationToken ct)
{
var startedAt = DateTimeOffset.UtcNow;
var psi = BuildStartInfo(endpoint, ctx);
using var process = new Process { StartInfo = psi, EnableRaisingEvents = true };
try
{
if (!process.Start())
{
return Failed(ctx.RunId, startedAt, "process failed to start");
}
}
catch (Exception ex)
{
return Failed(ctx.RunId, startedAt, $"launch error: {ex.Message}");
}
// stdin
if (endpoint.DataPassing.StdinJson)
{
try
{
if (ctx.BodyBytes.Length > 0)
await process.StandardInput.BaseStream.WriteAsync(ctx.BodyBytes, ct).ConfigureAwait(false);
}
catch (Exception ex)
{
return Failed(ctx.RunId, startedAt, $"stdin write failed: {ex.Message}");
}
finally
{
try { process.StandardInput.Close(); } catch { /* swallow */ }
}
}
else
{
try { process.StandardInput.Close(); } catch { /* swallow */ }
}
// Capture stdout/stderr in parallel, with per-stream cap.
var stdoutTask = ReadCappedAsync(process.StandardOutput, ct);
var stderrTask = ReadCappedAsync(process.StandardError, ct);
var timeout = TimeSpan.FromSeconds(Math.Max(1, endpoint.TimeoutSeconds));
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeoutCts.CancelAfter(timeout);
bool timedOut = false;
try
{
await process.WaitForExitAsync(timeoutCts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
timedOut = true;
try { process.Kill(entireProcessTree: true); } catch { /* swallow */ }
try { await process.WaitForExitAsync(CancellationToken.None).ConfigureAwait(false); } catch { /* swallow */ }
}
var (stdout, stdoutTrunc) = await stdoutTask.ConfigureAwait(false);
var (stderr, stderrTrunc) = await stderrTask.ConfigureAwait(false);
return new ExecutionResult
{
RunId = ctx.RunId,
ExitCode = timedOut ? -1 : process.ExitCode,
Stdout = stdout,
Stderr = stderr,
StdoutTruncated = stdoutTrunc,
StderrTruncated = stderrTrunc,
StartedAt = startedAt,
CompletedAt = DateTimeOffset.UtcNow,
TimedOut = timedOut,
};
}
private static ProcessStartInfo BuildStartInfo(EndpointConfig endpoint, ExecutionContext ctx)
{
var psi = new ProcessStartInfo
{
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
WorkingDirectory = string.IsNullOrEmpty(endpoint.WorkingDirectory)
? Environment.CurrentDirectory
: endpoint.WorkingDirectory!,
};
switch (endpoint.ExecutorType)
{
case ExecutorType.WindowsPowerShell:
psi.FileName = "powershell.exe";
AddPwshArgs(psi, endpoint);
break;
case ExecutorType.PwshCore:
psi.FileName = "pwsh.exe";
AddPwshArgs(psi, endpoint);
break;
case ExecutorType.Cmd:
psi.FileName = "cmd.exe";
psi.ArgumentList.Add("/c");
psi.ArgumentList.Add(ResolveCmdInvocation(endpoint));
break;
case ExecutorType.Executable:
psi.FileName = endpoint.ExecutablePath ?? "";
foreach (var staticArg in endpoint.ExecutableArgs)
psi.ArgumentList.Add(staticArg);
break;
default:
throw new ArgumentOutOfRangeException(nameof(endpoint.ExecutorType));
}
if (endpoint.DataPassing.ArgTemplate)
{
foreach (var arg in ArgTemplateRenderer.Render(endpoint.DataPassing.ArgTemplateString, ctx))
psi.ArgumentList.Add(arg);
}
if (endpoint.DataPassing.EnvVars)
{
foreach (var (k, v) in ctx.Headers)
psi.Environment[$"WEBHOOK_HEADER_{Sanitize(k)}"] = v;
foreach (var (k, v) in ctx.Query)
psi.Environment[$"WEBHOOK_QUERY_{Sanitize(k)}"] = v;
}
psi.Environment["WEBHOOK_RUN_ID"] = ctx.RunId;
psi.Environment["WEBHOOK_SLUG"] = ctx.Slug;
return psi;
}
private static void AddPwshArgs(ProcessStartInfo psi, EndpointConfig endpoint)
{
psi.ArgumentList.Add("-NoProfile");
psi.ArgumentList.Add("-NonInteractive");
psi.ArgumentList.Add("-ExecutionPolicy");
psi.ArgumentList.Add("Bypass");
if (!string.IsNullOrEmpty(endpoint.ScriptPath))
{
psi.ArgumentList.Add("-File");
psi.ArgumentList.Add(endpoint.ScriptPath);
}
else
{
psi.ArgumentList.Add("-Command");
psi.ArgumentList.Add(endpoint.InlineCommand ?? "");
}
}
private static string ResolveCmdInvocation(EndpointConfig endpoint)
{
if (!string.IsNullOrEmpty(endpoint.ScriptPath))
return endpoint.ScriptPath!;
return endpoint.InlineCommand ?? "";
}
private static string Sanitize(string key)
{
var sb = new StringBuilder(key.Length);
foreach (var ch in key)
{
if (char.IsLetterOrDigit(ch) || ch == '_')
sb.Append(char.ToUpperInvariant(ch));
else
sb.Append('_');
}
return sb.ToString();
}
private static async Task<(string Text, bool Truncated)> ReadCappedAsync(StreamReader reader, CancellationToken ct)
{
var sb = new StringBuilder();
var buffer = new char[4096];
bool truncated = false;
var byteEstimate = 0;
while (true)
{
int n;
try { n = await reader.ReadAsync(buffer, ct).ConfigureAwait(false); }
catch (OperationCanceledException) { break; }
catch (IOException) { break; }
if (n == 0) break;
// Cheap byte estimate (ASCII-ish); good enough as a guard rail.
if (!truncated)
{
if (byteEstimate + n > MaxOutputBytes)
{
var allowed = MaxOutputBytes - byteEstimate;
if (allowed > 0) sb.Append(buffer, 0, allowed);
truncated = true;
}
else
{
sb.Append(buffer, 0, n);
byteEstimate += n;
}
}
// Else keep draining without storing to keep the pipe from blocking.
}
return (sb.ToString(), truncated);
}
private static ExecutionResult Failed(string runId, DateTimeOffset startedAt, string reason) => new()
{
RunId = runId,
ExitCode = -1,
Stdout = "",
Stderr = "",
StartedAt = startedAt,
CompletedAt = DateTimeOffset.UtcNow,
TimedOut = false,
LaunchError = reason,
};
}
@@ -0,0 +1,91 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace WebhookServer.Core.Ipc;
/// <summary>
/// Operation discriminators for the named-pipe admin protocol. Request payload shape
/// is op-specific; the handler is responsible for binding <see cref="AdminRequest.Data"/>
/// to the right concrete type.
/// </summary>
public static class AdminOps
{
public const string GetConfig = "get-config";
public const string UpdateConfig = "update-config";
public const string ListEndpoints = "list-endpoints";
public const string CreateEndpoint = "create-endpoint";
public const string UpdateEndpoint = "update-endpoint";
public const string DeleteEndpoint = "delete-endpoint";
public const string EnableEndpoint = "enable-endpoint";
public const string DisableEndpoint = "disable-endpoint";
public const string GetStatus = "get-status";
public const string TailLogs = "tail-logs";
public const string BindHttps = "bind-https";
public const string RestartListener = "restart-listener";
public const string Ping = "ping";
}
public sealed class AdminRequest
{
[JsonPropertyName("op")] public string Op { get; set; } = "";
[JsonPropertyName("data")] public JsonElement? Data { get; set; }
}
public sealed class AdminResponse
{
[JsonPropertyName("ok")] public bool Ok { get; set; }
[JsonPropertyName("error")] public string? Error { get; set; }
[JsonPropertyName("data")] public JsonElement? Data { get; set; }
public static AdminResponse Success(object? payload = null)
{
if (payload is null) return new AdminResponse { Ok = true };
var doc = JsonSerializer.SerializeToDocument(payload, AdminProtocol.JsonOptions);
return new AdminResponse { Ok = true, Data = doc.RootElement.Clone() };
}
public static AdminResponse Failure(string error) => new() { Ok = false, Error = error };
}
public sealed class StatusInfo
{
public bool Running { get; set; }
public int HttpPort { get; set; }
public int? HttpsPort { get; set; }
public DateTimeOffset StartedAt { get; set; }
public int EndpointCount { get; set; }
}
public sealed class EndpointToggle
{
public Guid Id { get; set; }
}
public sealed class DeleteEndpointArgs
{
public Guid Id { get; set; }
}
public sealed class TailLogsArgs
{
public int LinesToBacklog { get; set; } = 100;
public bool Follow { get; set; } = true;
}
public sealed class LogLine
{
public DateTimeOffset Timestamp { get; set; }
public string Level { get; set; } = "Information";
public string Message { get; set; } = "";
}
public static class AdminProtocol
{
public static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
};
}
+29
View File
@@ -0,0 +1,29 @@
using System.Text;
using System.Text.Json;
namespace WebhookServer.Core.Ipc;
/// <summary>
/// Line-delimited JSON over a stream. One JSON object per line, terminated by '\n'.
/// </summary>
public static class PipeFraming
{
public static async Task WriteAsync<T>(Stream stream, T payload, CancellationToken ct)
{
var bytes = JsonSerializer.SerializeToUtf8Bytes(payload, AdminProtocol.JsonOptions);
await stream.WriteAsync(bytes, ct).ConfigureAwait(false);
await stream.WriteAsync(new byte[] { (byte)'\n' }, ct).ConfigureAwait(false);
await stream.FlushAsync(ct).ConfigureAwait(false);
}
public static async Task<T?> ReadAsync<T>(StreamReader reader, CancellationToken ct)
{
var line = await reader.ReadLineAsync(ct).ConfigureAwait(false);
if (line is null) return default;
if (string.IsNullOrWhiteSpace(line)) return default;
return JsonSerializer.Deserialize<T>(line, AdminProtocol.JsonOptions);
}
public static StreamReader CreateReader(Stream stream) =>
new(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, bufferSize: 4096, leaveOpen: true);
}
@@ -0,0 +1,33 @@
using System.IO.Pipes;
using System.Runtime.Versioning;
using System.Security.AccessControl;
using System.Security.Principal;
namespace WebhookServer.Core.Ipc;
/// <summary>
/// Builds a <see cref="PipeSecurity"/> that allows SYSTEM and the local Administrators
/// group full control, and denies everyone else. Required so non-admin users cannot
/// read or write the admin pipe even if they know the name.
/// </summary>
[SupportedOSPlatform("windows")]
public static class PipeSecurityFactory
{
public const string PipeName = "WebhookServerAdmin";
public static PipeSecurity Create()
{
var security = new PipeSecurity();
var system = new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null);
var administrators = new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null);
security.AddAccessRule(new PipeAccessRule(
system, PipeAccessRights.FullControl, AccessControlType.Allow));
security.AddAccessRule(new PipeAccessRule(
administrators, PipeAccessRights.FullControl, AccessControlType.Allow));
return security;
}
}
@@ -0,0 +1,6 @@
namespace WebhookServer.Core.Models;
public sealed class BearerOptions
{
public ProtectedString Secret { get; set; } = new();
}
@@ -0,0 +1,16 @@
namespace WebhookServer.Core.Models;
public sealed class CallbackConfig
{
public string Url { get; set; } = "";
public CallbackHttpMethod Method { get; set; } = CallbackHttpMethod.Post;
public AuthMode AuthMode { get; set; } = AuthMode.None;
public BearerOptions? Bearer { get; set; }
public HmacOptions? Hmac { get; set; }
public int TimeoutSeconds { get; set; } = 30;
public int MaxAttempts { get; set; } = 5;
public bool IncludeStdout { get; set; } = true;
public bool IncludeStderr { get; set; } = true;
public int MaxOutputBytes { get; set; } = 64 * 1024;
public CallbackTrigger Trigger { get; set; } = CallbackTrigger.OnComplete;
}
@@ -0,0 +1,14 @@
namespace WebhookServer.Core.Models;
public sealed class DataPassingOptions
{
public bool StdinJson { get; set; }
public bool EnvVars { get; set; }
public bool ArgTemplate { get; set; }
/// <summary>
/// Whitespace-separated list of template tokens; each rendered token becomes one argv entry.
/// Only used when <see cref="ArgTemplate"/> is true.
/// </summary>
public string? ArgTemplateString { get; set; }
}
@@ -0,0 +1,45 @@
namespace WebhookServer.Core.Models;
public sealed class EndpointConfig
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Slug { get; set; } = "";
public string? Description { get; set; }
public bool Enabled { get; set; } = true;
public List<string> AllowedClients { get; set; } = new();
public AuthMode AuthMode { get; set; } = AuthMode.None;
public BearerOptions? Bearer { get; set; }
public HmacOptions? Hmac { get; set; }
public ExecutorType ExecutorType { get; set; } = ExecutorType.WindowsPowerShell;
/// <summary>Path to a script file (.ps1, .bat, .cmd) when applicable.</summary>
public string? ScriptPath { get; set; }
/// <summary>Inline command body when no script file is used (PowerShell -Command, cmd /c).</summary>
public string? InlineCommand { get; set; }
/// <summary>Path to the executable when ExecutorType = Executable.</summary>
public string? ExecutablePath { get; set; }
/// <summary>Static argv prefix for Executable mode; the rendered ArgTemplate appends after.</summary>
public List<string> ExecutableArgs { get; set; } = new();
public string? WorkingDirectory { get; set; }
public DataPassingOptions DataPassing { get; set; } = new();
public ResponseMode ResponseMode { get; set; } = ResponseMode.Sync;
public int TimeoutSeconds { get; set; } = 60;
/// <summary>If true, a non-zero process exit produces 502 in sync mode (default true).</summary>
public bool FailOnNonZeroExit { get; set; } = true;
/// <summary>If true, requests are processed one at a time per endpoint.</summary>
public bool Serialize { get; set; }
public CallbackConfig? Callback { get; set; }
}
+55
View File
@@ -0,0 +1,55 @@
namespace WebhookServer.Core.Models;
public enum AuthMode
{
None = 0,
Bearer = 1,
Hmac = 2,
}
public enum HmacAlgorithm
{
Sha1 = 1,
Sha256 = 2,
Sha512 = 3,
}
public enum HmacEncoding
{
Hex = 0,
Base64 = 1,
}
public enum ExecutorType
{
WindowsPowerShell = 0,
PwshCore = 1,
Cmd = 2,
Executable = 3,
}
public enum ResponseMode
{
Sync = 0,
Async = 1,
}
public enum CallbackTrigger
{
OnComplete = 0,
OnSuccess = 1,
OnFailure = 2,
}
public enum CallbackHttpMethod
{
Post = 0,
Put = 1,
}
public enum HttpsBindingKind
{
None = 0,
PfxFile = 1,
CertStoreThumbprint = 2,
}
@@ -0,0 +1,10 @@
namespace WebhookServer.Core.Models;
public sealed class HmacOptions
{
public HmacAlgorithm Algorithm { get; set; } = HmacAlgorithm.Sha256;
public string HeaderName { get; set; } = "X-Hub-Signature-256";
public string Prefix { get; set; } = "sha256=";
public HmacEncoding Encoding { get; set; } = HmacEncoding.Hex;
public ProtectedString Secret { get; set; } = new();
}
@@ -0,0 +1,17 @@
using System.Security.Cryptography.X509Certificates;
namespace WebhookServer.Core.Models;
public sealed class HttpsBinding
{
public HttpsBindingKind Kind { get; set; } = HttpsBindingKind.None;
public int Port { get; set; } = 8443;
/// <summary>Path to a .pfx file when Kind = PfxFile.</summary>
public string? PfxPath { get; set; }
public ProtectedString? PfxPassword { get; set; }
/// <summary>Cert thumbprint when Kind = CertStoreThumbprint.</summary>
public string? Thumbprint { get; set; }
public StoreLocation StoreLocation { get; set; } = StoreLocation.LocalMachine;
}
@@ -0,0 +1,29 @@
using System.Text.Json.Serialization;
namespace WebhookServer.Core.Models;
/// <summary>
/// A secret value. <see cref="Encrypted"/> is the persistent (DPAPI-protected) form;
/// <see cref="Plaintext"/> is transient — the GUI sets it when submitting a new value
/// over the named pipe, and the service sets it after decrypting on load. Disk JSON
/// must never carry plaintext: <see cref="Storage.ConfigStore.SaveAsync"/> encrypts
/// then clears <see cref="Plaintext"/> before writing.
/// </summary>
public sealed class ProtectedString
{
[JsonPropertyName("encrypted")]
public string? Encrypted { get; set; }
[JsonPropertyName("plaintext")]
public string? Plaintext { get; set; }
[JsonIgnore]
public bool HasValue =>
!string.IsNullOrEmpty(Encrypted) || !string.IsNullOrEmpty(Plaintext);
public static ProtectedString FromPlaintext(string value) =>
new() { Plaintext = value };
public static ProtectedString FromEncrypted(string base64) =>
new() { Encrypted = base64 };
}
@@ -0,0 +1,17 @@
namespace WebhookServer.Core.Models;
public sealed class ServerConfig
{
public int HttpPort { get; set; } = 8080;
public HttpsBinding? HttpsBinding { get; set; }
/// <summary>
/// IPs/CIDRs allowed to set X-Forwarded-For. Empty = forwarded headers are ignored
/// and the direct connection IP is always used.
/// </summary>
public List<string> TrustedProxies { get; set; } = new();
public int LogRetentionDays { get; set; } = 14;
public List<EndpointConfig> Endpoints { get; set; } = new();
}
@@ -0,0 +1,27 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace WebhookServer.Core.Storage;
/// <summary>
/// Shared JSON serialization options used for persisting <see cref="Models.ServerConfig"/>
/// and for IPC payloads. Keeps formatting and naming consistent.
/// </summary>
public static class ConfigJson
{
public static readonly JsonSerializerOptions Pretty = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
};
public static readonly JsonSerializerOptions Compact = new()
{
WriteIndented = false,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
};
}
@@ -0,0 +1,118 @@
using System.Runtime.Versioning;
using System.Text.Json;
using WebhookServer.Core.Models;
namespace WebhookServer.Core.Storage;
/// <summary>
/// Loads and saves <see cref="ServerConfig"/> JSON. Round-trips secrets through DPAPI:
/// on save, any secret that has Plaintext but no Encrypted is protected first; on load
/// (when <see cref="DecryptSecrets"/> is called) all Encrypted blobs are unprotected
/// into Plaintext for in-memory use.
/// </summary>
[SupportedOSPlatform("windows")]
public sealed class ConfigStore
{
public string Path { get; }
public ConfigStore(string path)
{
Path = path;
}
public async Task<ServerConfig> LoadAsync(CancellationToken ct = default)
{
if (!File.Exists(Path))
return new ServerConfig();
await using var fs = File.OpenRead(Path);
var cfg = await JsonSerializer.DeserializeAsync<ServerConfig>(fs, ConfigJson.Pretty, ct).ConfigureAwait(false);
return cfg ?? new ServerConfig();
}
public async Task SaveAsync(ServerConfig config, CancellationToken ct = default)
{
EncryptSecrets(config);
ClearPlaintexts(config);
var dir = System.IO.Path.GetDirectoryName(Path);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
var tmp = Path + ".tmp";
await using (var fs = File.Create(tmp))
{
await JsonSerializer.SerializeAsync(fs, config, ConfigJson.Pretty, ct).ConfigureAwait(false);
await fs.FlushAsync(ct).ConfigureAwait(false);
}
// Atomic replace on the same volume.
File.Move(tmp, Path, overwrite: true);
}
public static void ClearPlaintexts(ServerConfig config)
{
foreach (var ep in config.Endpoints)
{
ClearOne(ep.Bearer?.Secret);
ClearOne(ep.Hmac?.Secret);
if (ep.Callback is { } cb)
{
ClearOne(cb.Bearer?.Secret);
ClearOne(cb.Hmac?.Secret);
}
}
ClearOne(config.HttpsBinding?.PfxPassword);
}
private static void ClearOne(ProtectedString? s)
{
if (s is null) return;
s.Plaintext = null;
}
public static void DecryptSecrets(ServerConfig config)
{
foreach (var ep in config.Endpoints)
{
DecryptOne(ep.Bearer?.Secret);
DecryptOne(ep.Hmac?.Secret);
if (ep.Callback is { } cb)
{
DecryptOne(cb.Bearer?.Secret);
DecryptOne(cb.Hmac?.Secret);
}
}
DecryptOne(config.HttpsBinding?.PfxPassword);
}
public static void EncryptSecrets(ServerConfig config)
{
foreach (var ep in config.Endpoints)
{
EncryptOne(ep.Bearer?.Secret);
EncryptOne(ep.Hmac?.Secret);
if (ep.Callback is { } cb)
{
EncryptOne(cb.Bearer?.Secret);
EncryptOne(cb.Hmac?.Secret);
}
}
EncryptOne(config.HttpsBinding?.PfxPassword);
}
private static void DecryptOne(ProtectedString? s)
{
if (s is null) return;
if (!string.IsNullOrEmpty(s.Plaintext)) return; // already populated
if (string.IsNullOrEmpty(s.Encrypted)) return;
s.Plaintext = DpapiSecret.Unprotect(s.Encrypted);
}
private static void EncryptOne(ProtectedString? s)
{
if (s is null) return;
if (string.IsNullOrEmpty(s.Plaintext)) return;
// Always re-encrypt when plaintext is present so secret rotation is honored.
s.Encrypted = DpapiSecret.Protect(s.Plaintext);
}
}
@@ -0,0 +1,30 @@
using System.Runtime.Versioning;
using System.Security.Cryptography;
using System.Text;
namespace WebhookServer.Core.Storage;
/// <summary>
/// DPAPI helpers using <see cref="DataProtectionScope.LocalMachine"/> so the same machine
/// (regardless of which Windows account the service runs under) can decrypt config secrets.
/// Wire format is plain base64 of the protected blob — caller wraps in JSON.
/// </summary>
[SupportedOSPlatform("windows")]
public static class DpapiSecret
{
public static string Protect(string plaintext)
{
if (string.IsNullOrEmpty(plaintext)) return "";
var bytes = Encoding.UTF8.GetBytes(plaintext);
var blob = ProtectedData.Protect(bytes, optionalEntropy: null, DataProtectionScope.LocalMachine);
return Convert.ToBase64String(blob);
}
public static string Unprotect(string base64)
{
if (string.IsNullOrEmpty(base64)) return "";
var blob = Convert.FromBase64String(base64);
var bytes = ProtectedData.Unprotect(blob, optionalEntropy: null, DataProtectionScope.LocalMachine);
return Encoding.UTF8.GetString(bytes);
}
}
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" />
<PackageReference Include="System.IO.Pipes.AccessControl" Version="5.0.0" />
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
</ItemGroup>
</Project>