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
+50
View File
@@ -0,0 +1,50 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{262B1849-BA2B-45DB-9DB1-5D4D9E1E1129}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebhookServer.Core", "src\WebhookServer.Core\WebhookServer.Core.csproj", "{0D2E9E23-BA5E-4C8C-A620-C263A21A78C4}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebhookServer.Service", "src\WebhookServer.Service\WebhookServer.Service.csproj", "{83E8FF0E-64EB-4D34-99DA-92BD1E12B670}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebhookServer.Gui", "src\WebhookServer.Gui\WebhookServer.Gui.csproj", "{ABF4583D-F821-4EAC-A053-56309FF7549E}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{A80D481D-557B-4C98-8C28-C8F4185B6537}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebhookServer.Core.Tests", "tests\WebhookServer.Core.Tests\WebhookServer.Core.Tests.csproj", "{27C42691-FF90-4885-A8E3-5EBB91D847DF}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{0D2E9E23-BA5E-4C8C-A620-C263A21A78C4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0D2E9E23-BA5E-4C8C-A620-C263A21A78C4}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0D2E9E23-BA5E-4C8C-A620-C263A21A78C4}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0D2E9E23-BA5E-4C8C-A620-C263A21A78C4}.Release|Any CPU.Build.0 = Release|Any CPU
{83E8FF0E-64EB-4D34-99DA-92BD1E12B670}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{83E8FF0E-64EB-4D34-99DA-92BD1E12B670}.Debug|Any CPU.Build.0 = Debug|Any CPU
{83E8FF0E-64EB-4D34-99DA-92BD1E12B670}.Release|Any CPU.ActiveCfg = Release|Any CPU
{83E8FF0E-64EB-4D34-99DA-92BD1E12B670}.Release|Any CPU.Build.0 = Release|Any CPU
{ABF4583D-F821-4EAC-A053-56309FF7549E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{ABF4583D-F821-4EAC-A053-56309FF7549E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{ABF4583D-F821-4EAC-A053-56309FF7549E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{ABF4583D-F821-4EAC-A053-56309FF7549E}.Release|Any CPU.Build.0 = Release|Any CPU
{27C42691-FF90-4885-A8E3-5EBB91D847DF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{27C42691-FF90-4885-A8E3-5EBB91D847DF}.Debug|Any CPU.Build.0 = Debug|Any CPU
{27C42691-FF90-4885-A8E3-5EBB91D847DF}.Release|Any CPU.ActiveCfg = Release|Any CPU
{27C42691-FF90-4885-A8E3-5EBB91D847DF}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{0D2E9E23-BA5E-4C8C-A620-C263A21A78C4} = {262B1849-BA2B-45DB-9DB1-5D4D9E1E1129}
{83E8FF0E-64EB-4D34-99DA-92BD1E12B670} = {262B1849-BA2B-45DB-9DB1-5D4D9E1E1129}
{ABF4583D-F821-4EAC-A053-56309FF7549E} = {262B1849-BA2B-45DB-9DB1-5D4D9E1E1129}
{27C42691-FF90-4885-A8E3-5EBB91D847DF} = {A80D481D-557B-4C98-8C28-C8F4185B6537}
EndGlobalSection
EndGlobal
+77
View File
@@ -0,0 +1,77 @@
<#
.SYNOPSIS
Installs and starts the WebhookServer Windows Service.
.DESCRIPTION
Creates the service via sc.exe pointed at the published WebhookServer.Service.exe,
sets it to start automatically, and starts it. Re-running the script with the same
BinaryPath updates the binary path of an existing service.
.PARAMETER BinaryPath
Full path to WebhookServer.Service.exe. Defaults to .\publish\WebhookServer.Service.exe
relative to the script.
.PARAMETER ServiceAccount
Account to run the service under. Defaults to LocalSystem.
For Active-Directory-aware hooks pass a domain user (DOMAIN\user) or a gMSA
(DOMAIN\svc-name$ — note the trailing $). Domain users require -Password.
Never pass LocalService — it has no network identity and cannot reach a DC.
.PARAMETER Password
Password for a domain-user account. Not required for LocalSystem, NetworkService,
LocalService, or gMSA accounts.
.EXAMPLE
.\install-service.ps1 -BinaryPath C:\WebhookServer\WebhookServer.Service.exe
.EXAMPLE
.\install-service.ps1 -BinaryPath C:\WebhookServer\WebhookServer.Service.exe `
-ServiceAccount 'CONTOSO\svc-webhookserver$'
#>
[CmdletBinding()]
param(
[string]$BinaryPath = (Join-Path $PSScriptRoot '..\publish\WebhookServer.Service.exe'),
[string]$ServiceName = 'WebhookServer',
[string]$DisplayName = 'Webhook Server',
[string]$ServiceAccount = 'LocalSystem',
[string]$Password
)
$ErrorActionPreference = 'Stop'
if ($ServiceAccount -ieq 'LocalService') {
throw 'LocalService has no network identity and cannot talk to a domain controller. Use LocalSystem, a domain user, or a gMSA instead.'
}
$BinaryPath = (Resolve-Path -LiteralPath $BinaryPath).Path
if (-not (Test-Path -LiteralPath $BinaryPath)) {
throw "Binary not found: $BinaryPath"
}
# Build sc.exe argv. Note: sc.exe is fussy about spaces — keep "key= value" format.
$obj = $ServiceAccount
$existing = sc.exe query $ServiceName 2>$null
if ($existing) {
Write-Host "Service '$ServiceName' already exists; updating binPath and account."
sc.exe config $ServiceName binPath= "`"$BinaryPath`"" obj= $obj $(if ($Password) { "password= $Password" }) | Out-Null
} else {
$args = @(
'create', $ServiceName,
"binPath=", "`"$BinaryPath`"",
"DisplayName=", "`"$DisplayName`"",
"start=", "auto",
"obj=", $obj
)
if ($Password) { $args += @('password=', $Password) }
sc.exe @args | Out-Null
}
# Configure failure recovery: restart the service on first/second failure, reset count after a day.
sc.exe failure $ServiceName reset= 86400 actions= restart/5000/restart/5000/restart/5000 | Out-Null
Write-Host "Starting service '$ServiceName'..."
sc.exe start $ServiceName | Out-Null
Start-Sleep -Seconds 1
sc.exe query $ServiceName
+38
View File
@@ -0,0 +1,38 @@
<#
.SYNOPSIS
Stops and removes the WebhookServer Windows Service.
.DESCRIPTION
Leaves C:\ProgramData\WebhookServer (config + logs) untouched. Pass -PurgeData
to remove that directory as well.
#>
[CmdletBinding()]
param(
[string]$ServiceName = 'WebhookServer',
[switch]$PurgeData
)
$ErrorActionPreference = 'Stop'
$existing = sc.exe query $ServiceName 2>$null
if (-not $existing) {
Write-Host "Service '$ServiceName' is not installed."
return
}
Write-Host "Stopping service '$ServiceName'..."
sc.exe stop $ServiceName 2>$null | Out-Null
Start-Sleep -Seconds 2
Write-Host "Deleting service '$ServiceName'..."
sc.exe delete $ServiceName | Out-Null
if ($PurgeData) {
$dataRoot = Join-Path $env:ProgramData 'WebhookServer'
if (Test-Path -LiteralPath $dataRoot) {
Write-Host "Removing $dataRoot"
Remove-Item -LiteralPath $dataRoot -Recurse -Force
}
}
Write-Host 'Done.'
@@ -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>
+11
View File
@@ -0,0 +1,11 @@
<Application x:Class="WebhookServer.Gui.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:conv="clr-namespace:WebhookServer.Gui.Converters"
StartupUri="MainWindow.xaml">
<Application.Resources>
<conv:NullToBoolConverter x:Key="NotNull"/>
<conv:BoolToBrushConverter x:Key="ConnFill"/>
<conv:StringEqualsConverter x:Key="StringEqualsConverter"/>
</Application.Resources>
</Application>
+13
View File
@@ -0,0 +1,13 @@
using System.Configuration;
using System.Data;
using System.Windows;
namespace WebhookServer.Gui;
/// <summary>
/// Interaction logic for App.xaml
/// </summary>
public partial class App : Application
{
}
+10
View File
@@ -0,0 +1,10 @@
using System.Windows;
[assembly:ThemeInfo(
ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located
//(used if a resource is not found in the page,
// or application resource dictionaries)
ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located
//(used if a resource is not found in the page,
// app, or any theme specific resource dictionaries)
)]
@@ -0,0 +1,35 @@
using System.Globalization;
using System.Windows.Data;
using System.Windows.Media;
namespace WebhookServer.Gui.Converters;
public sealed class NullToBoolConverter : IValueConverter
{
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
=> value is not null;
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException();
}
public sealed class StringEqualsConverter : IValueConverter
{
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
=> string.Equals(value as string, parameter as string, StringComparison.OrdinalIgnoreCase);
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> (value is bool b && b) ? parameter : Binding.DoNothing;
}
public sealed class BoolToBrushConverter : IValueConverter
{
public Brush TrueBrush { get; set; } = Brushes.SeaGreen;
public Brush FalseBrush { get; set; } = Brushes.IndianRed;
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
=> (value is bool b && b) ? TrueBrush : FalseBrush;
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException();
}
+87
View File
@@ -0,0 +1,87 @@
<Window x:Class="WebhookServer.Gui.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="clr-namespace:WebhookServer.Gui.ViewModels"
xmlns:models="clr-namespace:WebhookServer.Core.Models;assembly=WebhookServer.Core"
mc:Ignorable="d"
Title="Webhook Server" Height="600" Width="1000"
d:DataContext="{d:DesignInstance Type=vm:MainViewModel}">
<DockPanel LastChildFill="True">
<StatusBar DockPanel.Dock="Bottom">
<StatusBarItem>
<Ellipse Width="10" Height="10"
Fill="{Binding IsConnected, Converter={StaticResource ConnFill}}"/>
</StatusBarItem>
<StatusBarItem>
<TextBlock Text="{Binding ConnectionStatus}"/>
</StatusBarItem>
</StatusBar>
<ToolBar DockPanel.Dock="Top">
<Button Content="Refresh" Command="{Binding RefreshCommand}"/>
<Separator/>
<Button Content="Add" Command="{Binding AddEndpointCommand}"/>
<Button Content="Edit" Command="{Binding EditEndpointCommand}"
IsEnabled="{Binding SelectedEndpoint, Converter={StaticResource NotNull}}"/>
<Button Content="Delete" Command="{Binding DeleteEndpointCommand}"
IsEnabled="{Binding SelectedEndpoint, Converter={StaticResource NotNull}}"/>
<Separator/>
<Button Content="Server Settings…" Command="{Binding EditServerSettingsCommand}"/>
</ToolBar>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
<RowDefinition Height="5"/>
<RowDefinition Height="200"/>
</Grid.RowDefinitions>
<DataGrid Grid.Row="0"
ItemsSource="{Binding Endpoints}"
SelectedItem="{Binding SelectedEndpoint, Mode=TwoWay}"
AutoGenerateColumns="False"
CanUserAddRows="False"
CanUserDeleteRows="False"
IsReadOnly="True"
HeadersVisibility="Column">
<DataGrid.Columns>
<DataGridTemplateColumn Header="Enabled" Width="80">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate DataType="{x:Type models:EndpointConfig}">
<CheckBox IsChecked="{Binding Enabled, Mode=OneWay}"
Command="{Binding DataContext.ToggleEnabledCommand, RelativeSource={RelativeSource AncestorType=Window}}"
CommandParameter="{Binding}"/>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTextColumn Header="Slug" Binding="{Binding Slug}" Width="*"/>
<DataGridTextColumn Header="Auth" Binding="{Binding AuthMode}" Width="80"/>
<DataGridTextColumn Header="Executor" Binding="{Binding ExecutorType}" Width="140"/>
<DataGridTextColumn Header="Mode" Binding="{Binding ResponseMode}" Width="80"/>
<DataGridTextColumn Header="Description" Binding="{Binding Description}" Width="2*"/>
</DataGrid.Columns>
</DataGrid>
<GridSplitter Grid.Row="1" HorizontalAlignment="Stretch" Background="#DDD"/>
<DockPanel Grid.Row="2">
<Grid DockPanel.Dock="Top">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Text="Recent log entries" FontWeight="Bold" Margin="6,4"/>
<Button Grid.Column="1" Content="Refresh" Command="{Binding RefreshLogTailCommand}" Margin="6,2"/>
</Grid>
<TextBox Text="{Binding LogTail, Mode=OneWay}"
IsReadOnly="True"
FontFamily="Consolas"
VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Auto"
TextWrapping="NoWrap"/>
</DockPanel>
</Grid>
</DockPanel>
</Window>
+16
View File
@@ -0,0 +1,16 @@
using System.Windows;
using WebhookServer.Gui.Services;
using WebhookServer.Gui.ViewModels;
namespace WebhookServer.Gui;
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
var vm = new MainViewModel(new AdminPipeClient());
DataContext = vm;
Loaded += async (_, _) => await vm.RefreshCommand.ExecuteAsync(null);
}
}
@@ -0,0 +1,89 @@
using System.IO;
using System.IO.Pipes;
using System.Runtime.Versioning;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using WebhookServer.Core.Ipc;
using WebhookServer.Core.Models;
namespace WebhookServer.Gui.Services;
/// <summary>
/// Thin client around the admin named pipe. Each call connects, sends one request,
/// reads one response, and disconnects — keeps lifecycle simple at the cost of
/// connect-per-call overhead. The service single-instance pipe queues requests so
/// concurrent calls from the GUI serialize automatically.
/// </summary>
[SupportedOSPlatform("windows")]
public sealed class AdminPipeClient
{
public TimeSpan ConnectTimeout { get; init; } = TimeSpan.FromSeconds(5);
public async Task<AdminResponse> InvokeAsync(string op, object? data = null, CancellationToken ct = default)
{
var request = new AdminRequest
{
Op = op,
Data = data is null
? null
: JsonSerializer.SerializeToDocument(data, AdminProtocol.JsonOptions).RootElement.Clone(),
};
await using var pipe = new NamedPipeClientStream(
".",
PipeSecurityFactory.PipeName,
PipeDirection.InOut,
PipeOptions.Asynchronous);
await pipe.ConnectAsync((int)ConnectTimeout.TotalMilliseconds, ct).ConfigureAwait(false);
await PipeFraming.WriteAsync(pipe, request, ct).ConfigureAwait(false);
using var reader = PipeFraming.CreateReader(pipe);
var response = await PipeFraming.ReadAsync<AdminResponse>(reader, ct).ConfigureAwait(false);
return response ?? AdminResponse.Failure("empty response from service");
}
public async Task<T?> InvokeAsync<T>(string op, object? data = null, CancellationToken ct = default) where T : class
{
var resp = await InvokeAsync(op, data, ct).ConfigureAwait(false);
if (!resp.Ok || resp.Data is null) return null;
return resp.Data.Value.Deserialize<T>(AdminProtocol.JsonOptions);
}
public Task<AdminResponse> PingAsync(CancellationToken ct = default) =>
InvokeAsync(AdminOps.Ping, null, ct);
public Task<StatusInfo?> GetStatusAsync(CancellationToken ct = default) =>
InvokeAsync<StatusInfo>(AdminOps.GetStatus, null, ct);
public Task<ServerConfig?> GetConfigAsync(CancellationToken ct = default) =>
InvokeAsync<ServerConfig>(AdminOps.GetConfig, null, ct);
public Task<EndpointConfig?> CreateEndpointAsync(EndpointConfig endpoint, CancellationToken ct = default) =>
InvokeAsync<EndpointConfig>(AdminOps.CreateEndpoint, endpoint, ct);
public Task<EndpointConfig?> UpdateEndpointAsync(EndpointConfig endpoint, CancellationToken ct = default) =>
InvokeAsync<EndpointConfig>(AdminOps.UpdateEndpoint, endpoint, ct);
public Task<AdminResponse> DeleteEndpointAsync(Guid id, CancellationToken ct = default) =>
InvokeAsync(AdminOps.DeleteEndpoint, new DeleteEndpointArgs { Id = id }, ct);
public Task<AdminResponse> SetEndpointEnabledAsync(Guid id, bool enabled, CancellationToken ct = default) =>
InvokeAsync(enabled ? AdminOps.EnableEndpoint : AdminOps.DisableEndpoint, new EndpointToggle { Id = id }, ct);
public Task<AdminResponse> BindHttpsAsync(HttpsBinding? binding, CancellationToken ct = default) =>
InvokeAsync(AdminOps.BindHttps, binding, ct);
public Task<AdminResponse> RestartListenerAsync(CancellationToken ct = default) =>
InvokeAsync(AdminOps.RestartListener, null, ct);
public async Task<List<LogLine>> TailLogsAsync(int lines, CancellationToken ct = default)
{
var resp = await InvokeAsync(AdminOps.TailLogs, new TailLogsArgs { LinesToBacklog = lines, Follow = false }, ct).ConfigureAwait(false);
if (!resp.Ok || resp.Data is null) return new List<LogLine>();
var lst = resp.Data.Value.GetProperty("lines").Deserialize<List<LogLine>>(AdminProtocol.JsonOptions);
return lst ?? new List<LogLine>();
}
}
@@ -0,0 +1,76 @@
using System.Runtime.Versioning;
using System.Text.Json;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using WebhookServer.Core.Models;
using WebhookServer.Core.Storage;
namespace WebhookServer.Gui.ViewModels;
[SupportedOSPlatform("windows")]
public sealed partial class EndpointEditorViewModel : ObservableObject
{
public EndpointConfig Endpoint { get; }
public bool IsNew { get; }
[ObservableProperty] private bool _accepted;
public EndpointEditorViewModel(EndpointConfig template, bool isNew)
{
// Deep clone via JSON so cancel-on-close cleanly drops edits.
var json = JsonSerializer.Serialize(template, ConfigJson.Compact);
Endpoint = JsonSerializer.Deserialize<EndpointConfig>(json, ConfigJson.Compact)!;
Endpoint.Bearer ??= new BearerOptions();
Endpoint.Hmac ??= new HmacOptions();
IsNew = isNew;
}
public Array AuthModes { get; } = Enum.GetValues(typeof(AuthMode));
public Array ExecutorTypes { get; } = Enum.GetValues(typeof(ExecutorType));
public Array ResponseModes { get; } = Enum.GetValues(typeof(ResponseMode));
public string AllowedClientsText
{
get => string.Join(Environment.NewLine, Endpoint.AllowedClients);
set
{
Endpoint.AllowedClients = (value ?? "").Split(new[] { '\r', '\n', ',' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
OnPropertyChanged();
}
}
public string ExecutableArgsText
{
get => string.Join(" ", Endpoint.ExecutableArgs);
set
{
Endpoint.ExecutableArgs = (value ?? "").Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
OnPropertyChanged();
}
}
public string BearerSecretInput
{
get => "";
set
{
Endpoint.Bearer ??= new BearerOptions();
Endpoint.Bearer.Secret.Plaintext = string.IsNullOrEmpty(value) ? null : value;
OnPropertyChanged();
}
}
public string HmacSecretInput
{
get => "";
set
{
Endpoint.Hmac ??= new HmacOptions();
Endpoint.Hmac.Secret.Plaintext = string.IsNullOrEmpty(value) ? null : value;
OnPropertyChanged();
}
}
[RelayCommand]
private void Save() => Accepted = true;
}
@@ -0,0 +1,189 @@
using System.Collections.ObjectModel;
using System.Runtime.Versioning;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using WebhookServer.Core.Ipc;
using WebhookServer.Core.Models;
using WebhookServer.Gui.Services;
using WebhookServer.Gui.Views;
namespace WebhookServer.Gui.ViewModels;
[SupportedOSPlatform("windows")]
public sealed partial class MainViewModel : ObservableObject
{
private readonly AdminPipeClient _client;
public ObservableCollection<EndpointConfig> Endpoints { get; } = new();
[ObservableProperty] private EndpointConfig? _selectedEndpoint;
[ObservableProperty] private string _connectionStatus = "Disconnected";
[ObservableProperty] private bool _isConnected;
[ObservableProperty] private string _logTail = "";
[ObservableProperty] private ServerConfig _serverConfig = new();
public MainViewModel(AdminPipeClient client)
{
_client = client;
}
[RelayCommand]
private async Task RefreshAsync()
{
try
{
var status = await _client.GetStatusAsync().ConfigureAwait(false);
var config = await _client.GetConfigAsync().ConfigureAwait(false);
Application.Current.Dispatcher.Invoke(() =>
{
IsConnected = status?.Running == true;
ConnectionStatus = IsConnected
? $"Connected — HTTP {status!.HttpPort}{(status.HttpsPort.HasValue ? $" / HTTPS {status.HttpsPort}" : "")}"
: "Disconnected";
Endpoints.Clear();
if (config is not null)
{
ServerConfig = config;
foreach (var ep in config.Endpoints) Endpoints.Add(ep);
}
});
}
catch (Exception ex)
{
Application.Current.Dispatcher.Invoke(() =>
{
IsConnected = false;
ConnectionStatus = $"Disconnected: {ex.Message}";
});
}
await RefreshLogTailAsync().ConfigureAwait(false);
}
[RelayCommand]
private async Task RefreshLogTailAsync()
{
try
{
var lines = await _client.TailLogsAsync(100).ConfigureAwait(false);
var text = new StringBuilder();
foreach (var line in lines) text.AppendLine(line.Message);
Application.Current.Dispatcher.Invoke(() => LogTail = text.ToString());
}
catch
{
// ignore — main connection state already reflects pipe failure
}
}
[RelayCommand]
private async Task AddEndpointAsync()
{
var draft = new EndpointConfig { Id = Guid.NewGuid(), Slug = "new-hook" };
var dlg = new EndpointEditor { Owner = Application.Current.MainWindow };
var vm = new EndpointEditorViewModel(draft, isNew: true);
dlg.DataContext = vm;
if (dlg.ShowDialog() != true) return;
try
{
await _client.CreateEndpointAsync(vm.Endpoint).ConfigureAwait(false);
await RefreshAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
ShowError("Create failed", ex);
}
}
[RelayCommand]
private async Task EditEndpointAsync()
{
if (SelectedEndpoint is null) return;
var dlg = new EndpointEditor { Owner = Application.Current.MainWindow };
var vm = new EndpointEditorViewModel(SelectedEndpoint, isNew: false);
dlg.DataContext = vm;
if (dlg.ShowDialog() != true) return;
try
{
await _client.UpdateEndpointAsync(vm.Endpoint).ConfigureAwait(false);
await RefreshAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
ShowError("Update failed", ex);
}
}
[RelayCommand]
private async Task DeleteEndpointAsync()
{
if (SelectedEndpoint is null) return;
var ok = MessageBox.Show(
$"Delete endpoint '{SelectedEndpoint.Slug}'?",
"Confirm",
MessageBoxButton.OKCancel,
MessageBoxImage.Warning);
if (ok != MessageBoxResult.OK) return;
try
{
await _client.DeleteEndpointAsync(SelectedEndpoint.Id).ConfigureAwait(false);
await RefreshAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
ShowError("Delete failed", ex);
}
}
[RelayCommand]
private async Task ToggleEnabledAsync(EndpointConfig? ep)
{
if (ep is null) return;
try
{
await _client.SetEndpointEnabledAsync(ep.Id, !ep.Enabled).ConfigureAwait(false);
await RefreshAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
ShowError("Toggle failed", ex);
}
}
[RelayCommand]
private async Task EditServerSettingsAsync()
{
var dlg = new ServerSettings { Owner = Application.Current.MainWindow };
var vm = new ServerSettingsViewModel(ServerConfig);
dlg.DataContext = vm;
if (dlg.ShowDialog() != true) return;
try
{
ServerConfig.HttpPort = vm.HttpPort;
ServerConfig.TrustedProxies = vm.TrustedProxiesList;
ServerConfig.HttpsBinding = vm.BuildBinding();
await _client.InvokeAsync(AdminOps.UpdateConfig, ServerConfig).ConfigureAwait(false);
await RefreshAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
ShowError("Save failed", ex);
}
}
private static void ShowError(string title, Exception ex)
{
Application.Current.Dispatcher.Invoke(() =>
MessageBox.Show(ex.Message, title, MessageBoxButton.OK, MessageBoxImage.Error));
}
}
@@ -0,0 +1,60 @@
using System.Runtime.Versioning;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using WebhookServer.Core.Models;
namespace WebhookServer.Gui.ViewModels;
[SupportedOSPlatform("windows")]
public sealed partial class ServerSettingsViewModel : ObservableObject
{
[ObservableProperty] private int _httpPort;
[ObservableProperty] private int _httpsPort;
[ObservableProperty] private bool _httpsEnabled;
[ObservableProperty] private string _httpsMode = "PfxFile";
[ObservableProperty] private string _pfxPath = "";
[ObservableProperty] private string _pfxPasswordInput = "";
[ObservableProperty] private string _thumbprint = "";
[ObservableProperty] private string _trustedProxiesText = "";
public bool Accepted { get; private set; }
public ServerSettingsViewModel(ServerConfig config)
{
HttpPort = config.HttpPort;
TrustedProxiesText = string.Join(Environment.NewLine, config.TrustedProxies);
var b = config.HttpsBinding;
HttpsEnabled = b is not null && b.Kind != HttpsBindingKind.None;
HttpsPort = b?.Port ?? 8443;
HttpsMode = b?.Kind == HttpsBindingKind.CertStoreThumbprint ? "Thumbprint" : "PfxFile";
PfxPath = b?.PfxPath ?? "";
Thumbprint = b?.Thumbprint ?? "";
}
public List<string> TrustedProxiesList =>
(TrustedProxiesText ?? "").Split(new[] { '\r', '\n', ',' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
public HttpsBinding? BuildBinding()
{
if (!HttpsEnabled) return null;
var binding = new HttpsBinding { Port = HttpsPort };
if (string.Equals(HttpsMode, "Thumbprint", StringComparison.OrdinalIgnoreCase))
{
binding.Kind = HttpsBindingKind.CertStoreThumbprint;
binding.Thumbprint = Thumbprint?.Trim();
}
else
{
binding.Kind = HttpsBindingKind.PfxFile;
binding.PfxPath = PfxPath;
if (!string.IsNullOrEmpty(PfxPasswordInput))
binding.PfxPassword = ProtectedString.FromPlaintext(PfxPasswordInput);
}
return binding;
}
[RelayCommand]
private void Save() => Accepted = true;
}
@@ -0,0 +1,155 @@
<Window x:Class="WebhookServer.Gui.Views.EndpointEditor"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="clr-namespace:WebhookServer.Gui.ViewModels"
mc:Ignorable="d"
Title="Endpoint" Height="700" Width="640"
WindowStartupLocation="CenterOwner"
d:DataContext="{d:DesignInstance Type=vm:EndpointEditorViewModel}">
<DockPanel Margin="12">
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0">
<Button Content="Save" Width="80" Margin="0,0,8,0" IsDefault="True" Click="OnSave" />
<Button Content="Cancel" Width="80" IsCancel="True"/>
</StackPanel>
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel>
<GroupBox Header="Identity" Padding="6">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="120"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition/>
<RowDefinition/>
<RowDefinition/>
</Grid.RowDefinitions>
<TextBlock Text="Slug" VerticalAlignment="Center"/>
<TextBox Grid.Column="1" Text="{Binding Endpoint.Slug, UpdateSourceTrigger=PropertyChanged}"/>
<TextBlock Grid.Row="1" Text="Description" VerticalAlignment="Center" Margin="0,4,0,0"/>
<TextBox Grid.Row="1" Grid.Column="1" Text="{Binding Endpoint.Description, UpdateSourceTrigger=PropertyChanged}" Margin="0,4,0,0"/>
<TextBlock Grid.Row="2" Text="Enabled" VerticalAlignment="Center" Margin="0,4,0,0"/>
<CheckBox Grid.Row="2" Grid.Column="1" IsChecked="{Binding Endpoint.Enabled}" VerticalAlignment="Center" Margin="0,4,0,0"/>
</Grid>
</GroupBox>
<GroupBox Header="Auth" Padding="6" Margin="0,8,0,0">
<StackPanel>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="120"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBlock Text="Mode" VerticalAlignment="Center"/>
<ComboBox Grid.Column="1" ItemsSource="{Binding AuthModes}"
SelectedItem="{Binding Endpoint.AuthMode, Mode=TwoWay}"/>
</Grid>
<Grid Margin="0,4,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="120"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBlock Text="Bearer secret" VerticalAlignment="Center"/>
<PasswordBox Grid.Column="1" PasswordChanged="OnBearerPasswordChanged"/>
</Grid>
<Grid Margin="0,4,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="120"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBlock Text="HMAC secret" VerticalAlignment="Center"/>
<PasswordBox Grid.Column="1" PasswordChanged="OnHmacPasswordChanged"/>
</Grid>
<Grid Margin="0,4,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="120"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBlock Text="HMAC header" VerticalAlignment="Center"/>
<TextBox Grid.Column="1" Text="{Binding Endpoint.Hmac.HeaderName, UpdateSourceTrigger=PropertyChanged}"/>
</Grid>
</StackPanel>
</GroupBox>
<GroupBox Header="IP allowlist (one per line, IP or CIDR)" Padding="6" Margin="0,8,0,0">
<TextBox Text="{Binding AllowedClientsText, UpdateSourceTrigger=LostFocus}" AcceptsReturn="True" MinHeight="60" TextWrapping="Wrap" VerticalScrollBarVisibility="Auto"/>
</GroupBox>
<GroupBox Header="Executor" Padding="6" Margin="0,8,0,0">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="120"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition/>
<RowDefinition/>
<RowDefinition/>
<RowDefinition/>
<RowDefinition/>
<RowDefinition/>
</Grid.RowDefinitions>
<TextBlock Text="Type" VerticalAlignment="Center"/>
<ComboBox Grid.Column="1" ItemsSource="{Binding ExecutorTypes}" SelectedItem="{Binding Endpoint.ExecutorType, Mode=TwoWay}"/>
<TextBlock Grid.Row="1" Text="Script path" VerticalAlignment="Center" Margin="0,4,0,0"/>
<TextBox Grid.Row="1" Grid.Column="1" Text="{Binding Endpoint.ScriptPath, UpdateSourceTrigger=PropertyChanged}" Margin="0,4,0,0"/>
<TextBlock Grid.Row="2" Text="Inline command" VerticalAlignment="Center" Margin="0,4,0,0"/>
<TextBox Grid.Row="2" Grid.Column="1" Text="{Binding Endpoint.InlineCommand, UpdateSourceTrigger=PropertyChanged}" Margin="0,4,0,0" AcceptsReturn="True" MinHeight="40"/>
<TextBlock Grid.Row="3" Text="Executable" VerticalAlignment="Center" Margin="0,4,0,0"/>
<TextBox Grid.Row="3" Grid.Column="1" Text="{Binding Endpoint.ExecutablePath, UpdateSourceTrigger=PropertyChanged}" Margin="0,4,0,0"/>
<TextBlock Grid.Row="4" Text="Static args" VerticalAlignment="Center" Margin="0,4,0,0"/>
<TextBox Grid.Row="4" Grid.Column="1" Text="{Binding ExecutableArgsText, UpdateSourceTrigger=LostFocus}" Margin="0,4,0,0"/>
<TextBlock Grid.Row="5" Text="Working dir" VerticalAlignment="Center" Margin="0,4,0,0"/>
<TextBox Grid.Row="5" Grid.Column="1" Text="{Binding Endpoint.WorkingDirectory, UpdateSourceTrigger=PropertyChanged}" Margin="0,4,0,0"/>
</Grid>
</GroupBox>
<GroupBox Header="Data passing" Padding="6" Margin="0,8,0,0">
<StackPanel>
<CheckBox Content="JSON body to stdin" IsChecked="{Binding Endpoint.DataPassing.StdinJson}"/>
<CheckBox Content="Headers/query as env vars (WEBHOOK_HEADER_*, WEBHOOK_QUERY_*)" IsChecked="{Binding Endpoint.DataPassing.EnvVars}" Margin="0,4,0,0"/>
<CheckBox Content="Argument template" IsChecked="{Binding Endpoint.DataPassing.ArgTemplate}" Margin="0,4,0,0"/>
<TextBox Text="{Binding Endpoint.DataPassing.ArgTemplateString, UpdateSourceTrigger=PropertyChanged}" Margin="0,4,0,0" />
<TextBlock Text="Tokens: {{body.foo}} {{header.X-Foo}} {{query.bar}} {{route.slug}}" Foreground="Gray" Margin="0,2,0,0"/>
</StackPanel>
</GroupBox>
<GroupBox Header="Response" Padding="6" Margin="0,8,0,0">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="120"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition/>
<RowDefinition/>
<RowDefinition/>
<RowDefinition/>
</Grid.RowDefinitions>
<TextBlock Text="Mode" VerticalAlignment="Center"/>
<ComboBox Grid.Column="1" ItemsSource="{Binding ResponseModes}" SelectedItem="{Binding Endpoint.ResponseMode, Mode=TwoWay}"/>
<TextBlock Grid.Row="1" Text="Timeout (sec)" VerticalAlignment="Center" Margin="0,4,0,0"/>
<TextBox Grid.Row="1" Grid.Column="1" Text="{Binding Endpoint.TimeoutSeconds, UpdateSourceTrigger=PropertyChanged}" Margin="0,4,0,0"/>
<TextBlock Grid.Row="2" Text="Fail on non-zero exit" VerticalAlignment="Center" Margin="0,4,0,0"/>
<CheckBox Grid.Row="2" Grid.Column="1" IsChecked="{Binding Endpoint.FailOnNonZeroExit}" VerticalAlignment="Center" Margin="0,4,0,0"/>
<TextBlock Grid.Row="3" Text="Serialize runs" VerticalAlignment="Center" Margin="0,4,0,0"/>
<CheckBox Grid.Row="3" Grid.Column="1" IsChecked="{Binding Endpoint.Serialize}" VerticalAlignment="Center" Margin="0,4,0,0"/>
</Grid>
</GroupBox>
</StackPanel>
</ScrollViewer>
</DockPanel>
</Window>
@@ -0,0 +1,33 @@
using System.Windows;
using System.Windows.Controls;
using WebhookServer.Gui.ViewModels;
namespace WebhookServer.Gui.Views;
public partial class EndpointEditor : Window
{
public EndpointEditor()
{
InitializeComponent();
}
private void OnSave(object sender, RoutedEventArgs e)
{
if (DataContext is EndpointEditorViewModel vm)
vm.SaveCommand.Execute(null);
DialogResult = true;
Close();
}
private void OnBearerPasswordChanged(object sender, RoutedEventArgs e)
{
if (DataContext is EndpointEditorViewModel vm && sender is PasswordBox box)
vm.BearerSecretInput = box.Password;
}
private void OnHmacPasswordChanged(object sender, RoutedEventArgs e)
{
if (DataContext is EndpointEditorViewModel vm && sender is PasswordBox box)
vm.HmacSecretInput = box.Password;
}
}
@@ -0,0 +1,74 @@
<Window x:Class="WebhookServer.Gui.Views.ServerSettings"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="clr-namespace:WebhookServer.Gui.ViewModels"
mc:Ignorable="d"
Title="Server Settings" Height="500" Width="540"
WindowStartupLocation="CenterOwner"
d:DataContext="{d:DesignInstance Type=vm:ServerSettingsViewModel}">
<DockPanel Margin="12">
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0">
<Button Content="Save" Width="80" Margin="0,0,8,0" IsDefault="True" Click="OnSave"/>
<Button Content="Cancel" Width="80" IsCancel="True"/>
</StackPanel>
<StackPanel>
<GroupBox Header="HTTP" Padding="6">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="160"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBlock Text="HTTP port" VerticalAlignment="Center"/>
<TextBox Grid.Column="1" Text="{Binding HttpPort, UpdateSourceTrigger=PropertyChanged}"/>
</Grid>
</GroupBox>
<GroupBox Header="HTTPS" Padding="6" Margin="0,8,0,0">
<StackPanel>
<CheckBox Content="Enabled" IsChecked="{Binding HttpsEnabled}"/>
<Grid Margin="0,6,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="160"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition/>
<RowDefinition/>
<RowDefinition/>
<RowDefinition/>
<RowDefinition/>
</Grid.RowDefinitions>
<TextBlock Text="HTTPS port" VerticalAlignment="Center"/>
<TextBox Grid.Column="1" Text="{Binding HttpsPort, UpdateSourceTrigger=PropertyChanged}"/>
<TextBlock Grid.Row="1" Text="Mode" VerticalAlignment="Center" Margin="0,4,0,0"/>
<StackPanel Grid.Row="1" Grid.Column="1" Orientation="Horizontal" Margin="0,4,0,0">
<RadioButton GroupName="HttpsMode" Content="PFX file"
IsChecked="{Binding HttpsMode, Converter={StaticResource StringEqualsConverter}, ConverterParameter=PfxFile}"
Tag="PfxFile" Checked="OnModeChecked"/>
<RadioButton GroupName="HttpsMode" Content="Cert store thumbprint" Margin="12,0,0,0"
IsChecked="{Binding HttpsMode, Converter={StaticResource StringEqualsConverter}, ConverterParameter=Thumbprint}"
Tag="Thumbprint" Checked="OnModeChecked"/>
</StackPanel>
<TextBlock Grid.Row="2" Text="PFX path" VerticalAlignment="Center" Margin="0,4,0,0"/>
<TextBox Grid.Row="2" Grid.Column="1" Text="{Binding PfxPath, UpdateSourceTrigger=PropertyChanged}" Margin="0,4,0,0"/>
<TextBlock Grid.Row="3" Text="PFX password" VerticalAlignment="Center" Margin="0,4,0,0"/>
<PasswordBox Grid.Row="3" Grid.Column="1" PasswordChanged="OnPfxPasswordChanged" Margin="0,4,0,0"/>
<TextBlock Grid.Row="4" Text="Thumbprint" VerticalAlignment="Center" Margin="0,4,0,0"/>
<TextBox Grid.Row="4" Grid.Column="1" Text="{Binding Thumbprint, UpdateSourceTrigger=PropertyChanged}" Margin="0,4,0,0"/>
</Grid>
</StackPanel>
</GroupBox>
<GroupBox Header="Trusted proxies (one per line, IP or CIDR)" Padding="6" Margin="0,8,0,0">
<TextBox Text="{Binding TrustedProxiesText, UpdateSourceTrigger=LostFocus}" AcceptsReturn="True" MinHeight="80" TextWrapping="Wrap" VerticalScrollBarVisibility="Auto"/>
</GroupBox>
</StackPanel>
</DockPanel>
</Window>
@@ -0,0 +1,33 @@
using System.Windows;
using System.Windows.Controls;
using WebhookServer.Gui.ViewModels;
namespace WebhookServer.Gui.Views;
public partial class ServerSettings : Window
{
public ServerSettings()
{
InitializeComponent();
}
private void OnSave(object sender, RoutedEventArgs e)
{
if (DataContext is ServerSettingsViewModel vm)
vm.SaveCommand.Execute(null);
DialogResult = true;
Close();
}
private void OnPfxPasswordChanged(object sender, RoutedEventArgs e)
{
if (DataContext is ServerSettingsViewModel vm && sender is PasswordBox box)
vm.PfxPasswordInput = box.Password;
}
private void OnModeChecked(object sender, RoutedEventArgs e)
{
if (DataContext is ServerSettingsViewModel vm && sender is RadioButton rb && rb.Tag is string tag)
vm.HttpsMode = tag;
}
}
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\WebhookServer.Core\WebhookServer.Core.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.3.2" />
</ItemGroup>
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF>
</PropertyGroup>
</Project>
@@ -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);
}
+116
View File
@@ -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));
}
+16
View File
@@ -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");
}
+115
View File
@@ -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;
}
}
+285
View File
@@ -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"
}
}
}
@@ -0,0 +1,74 @@
using System.Text;
using System.Text.Json.Nodes;
using WebhookServer.Core.Execution;
using ExecCtx = WebhookServer.Core.Execution.ExecutionContext;
using Xunit;
namespace WebhookServer.Core.Tests;
public class ArgTemplateRendererTests
{
private static ExecCtx Ctx(string body, Dictionary<string, string>? headers = null, Dictionary<string, string>? query = null)
{
var bytes = Encoding.UTF8.GetBytes(body);
return new ExecCtx
{
RunId = "r",
Slug = "s",
BodyBytes = bytes,
BodyString = body,
BodyJson = body.Length == 0 ? null : JsonNode.Parse(body),
Headers = headers ?? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase),
Query = query ?? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase),
Route = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) { { "slug", "deploy" } },
};
}
[Fact]
public void Whitespace_separated_tokens_become_separate_args()
{
var ctx = Ctx("{\"name\":\"alice\",\"id\":7}");
var args = ArgTemplateRenderer.Render("{{body.name}} {{body.id}}", ctx);
Assert.Equal(new[] { "alice", "7" }, args);
}
[Fact]
public void Header_lookup_is_case_insensitive()
{
var ctx = Ctx("", headers: new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) { ["X-GitHub-Event"] = "push" });
var args = ArgTemplateRenderer.Render("{{header.x-github-event}}", ctx);
Assert.Equal(new[] { "push" }, args);
}
[Fact]
public void Missing_path_renders_empty_string()
{
var ctx = Ctx("{}");
var args = ArgTemplateRenderer.Render("{{body.nope}}", ctx);
Assert.Equal(new[] { "" }, args);
}
[Fact]
public void Route_value_resolves()
{
var ctx = Ctx("");
var args = ArgTemplateRenderer.Render("{{route.slug}}", ctx);
Assert.Equal(new[] { "deploy" }, args);
}
[Fact]
public void Multiple_substitutions_in_one_token_are_concatenated()
{
var ctx = Ctx("{\"a\":\"x\",\"b\":\"y\"}");
var args = ArgTemplateRenderer.Render("{{body.a}}-{{body.b}}", ctx);
Assert.Equal(new[] { "x-y" }, args);
}
[Fact]
public void Nested_json_path_resolves()
{
var ctx = Ctx("{\"repo\":{\"name\":\"acme\"}}");
var args = ArgTemplateRenderer.Render("{{body.repo.name}}", ctx);
Assert.Equal(new[] { "acme" }, args);
}
}
@@ -0,0 +1,27 @@
using WebhookServer.Core.Auth;
using Xunit;
namespace WebhookServer.Core.Tests;
public class BearerVerifierTests
{
[Fact]
public void Accepts_correct_token() =>
Assert.True(BearerVerifier.Verify("Bearer s3cret", "s3cret").Success);
[Fact]
public void Rejects_wrong_token() =>
Assert.False(BearerVerifier.Verify("Bearer nope", "s3cret").Success);
[Fact]
public void Rejects_missing_header() =>
Assert.False(BearerVerifier.Verify(null, "s3cret").Success);
[Fact]
public void Rejects_non_bearer_scheme() =>
Assert.False(BearerVerifier.Verify("Basic s3cret", "s3cret").Success);
[Fact]
public void Rejects_when_server_secret_empty() =>
Assert.False(BearerVerifier.Verify("Bearer s3cret", "").Success);
}
@@ -0,0 +1,52 @@
using System.Runtime.InteropServices;
using WebhookServer.Core.Models;
using WebhookServer.Core.Storage;
using Xunit;
namespace WebhookServer.Core.Tests;
public class ConfigStoreTests
{
[Fact]
public async Task Save_then_load_preserves_endpoints_and_encrypts_secrets()
{
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return;
var path = Path.Combine(Path.GetTempPath(), $"webhook-test-{Guid.NewGuid():N}.json");
try
{
var store = new ConfigStore(path);
var cfg = new ServerConfig
{
HttpPort = 9000,
Endpoints =
{
new EndpointConfig
{
Slug = "deploy",
AuthMode = AuthMode.Bearer,
Bearer = new BearerOptions { Secret = ProtectedString.FromPlaintext("topsecret") },
},
},
};
await store.SaveAsync(cfg);
// Persisted config must not contain plaintext.
var rawJson = await File.ReadAllTextAsync(path);
Assert.DoesNotContain("topsecret", rawJson);
Assert.Contains("encrypted", rawJson);
var reloaded = await store.LoadAsync();
ConfigStore.DecryptSecrets(reloaded);
var ep = Assert.Single(reloaded.Endpoints);
Assert.Equal("deploy", ep.Slug);
Assert.Equal("topsecret", ep.Bearer!.Secret.Plaintext);
}
finally
{
if (File.Exists(path)) File.Delete(path);
}
}
}
@@ -0,0 +1,27 @@
using System.Runtime.InteropServices;
using WebhookServer.Core.Storage;
using Xunit;
namespace WebhookServer.Core.Tests;
public class DpapiSecretTests
{
[Fact]
public void Round_trip_recovers_original_value()
{
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return;
var original = "topsecret-é-🚀";
var encrypted = DpapiSecret.Protect(original);
Assert.NotEmpty(encrypted);
var decrypted = DpapiSecret.Unprotect(encrypted);
Assert.Equal(original, decrypted);
}
[Fact]
public void Empty_string_round_trips_as_empty()
{
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return;
Assert.Equal("", DpapiSecret.Unprotect(DpapiSecret.Protect("")));
}
}
@@ -0,0 +1,79 @@
using System.Security.Cryptography;
using System.Text;
using WebhookServer.Core.Auth;
using WebhookServer.Core.Models;
using Xunit;
namespace WebhookServer.Core.Tests;
public class HmacVerifierTests
{
[Fact]
public void Compute_matches_GitHub_style_signature()
{
var body = Encoding.UTF8.GetBytes("{\"x\":1}");
var secret = "topsecret";
var hex = HmacVerifier.Compute(body, secret, HmacAlgorithm.Sha256, HmacEncoding.Hex);
// Cross-check against direct HMACSHA256 to ensure no encoding drift.
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var expected = Convert.ToHexString(hmac.ComputeHash(body)).ToLowerInvariant();
Assert.Equal(expected, hex);
}
[Fact]
public void Verify_accepts_correct_signature_with_prefix()
{
var body = Encoding.UTF8.GetBytes("hello world");
var secret = "shhh";
var sig = HmacVerifier.Compute(body, secret, HmacAlgorithm.Sha256, HmacEncoding.Hex);
var options = new HmacOptions { Secret = ProtectedString.FromPlaintext(secret) };
var result = HmacVerifier.Verify(body, $"sha256={sig}", options);
Assert.True(result.Success);
}
[Fact]
public void Verify_rejects_wrong_signature()
{
var body = Encoding.UTF8.GetBytes("payload");
var options = new HmacOptions { Secret = ProtectedString.FromPlaintext("right") };
var sig = HmacVerifier.Compute(body, "wrong", HmacAlgorithm.Sha256, HmacEncoding.Hex);
var result = HmacVerifier.Verify(body, $"sha256={sig}", options);
Assert.False(result.Success);
}
[Fact]
public void Verify_rejects_when_prefix_missing()
{
var body = Encoding.UTF8.GetBytes("payload");
var options = new HmacOptions { Secret = ProtectedString.FromPlaintext("k") };
var sig = HmacVerifier.Compute(body, "k", HmacAlgorithm.Sha256, HmacEncoding.Hex);
var result = HmacVerifier.Verify(body, sig, options); // no "sha256=" prefix
Assert.False(result.Success);
}
[Fact]
public void Verify_handles_base64_encoding()
{
var body = Encoding.UTF8.GetBytes("payload");
var secret = "abc";
var sig = HmacVerifier.Compute(body, secret, HmacAlgorithm.Sha256, HmacEncoding.Base64);
var options = new HmacOptions
{
Encoding = HmacEncoding.Base64,
Prefix = "",
Secret = ProtectedString.FromPlaintext(secret),
};
var result = HmacVerifier.Verify(body, sig, options);
Assert.True(result.Success);
}
}
@@ -0,0 +1,57 @@
using System.Net;
using WebhookServer.Core.Auth;
using Xunit;
namespace WebhookServer.Core.Tests;
public class IpAllowListTests
{
[Fact]
public void Empty_list_allows_everything()
{
var list = IpAllowList.Parse(Array.Empty<string>());
Assert.True(list.IsEmpty);
Assert.True(list.Contains(IPAddress.Parse("1.2.3.4")));
Assert.True(list.Contains(IPAddress.Parse("::1")));
}
[Fact]
public void Single_v4_matches_exact_only()
{
var list = IpAllowList.Parse(new[] { "192.168.1.10" });
Assert.True(list.Contains(IPAddress.Parse("192.168.1.10")));
Assert.False(list.Contains(IPAddress.Parse("192.168.1.11")));
}
[Fact]
public void V4_cidr_matches_inside_range()
{
var list = IpAllowList.Parse(new[] { "10.0.0.0/8" });
Assert.True(list.Contains(IPAddress.Parse("10.10.1.42")));
Assert.False(list.Contains(IPAddress.Parse("11.0.0.1")));
}
[Fact]
public void V6_cidr_matches_inside_range()
{
var list = IpAllowList.Parse(new[] { "fd00::/8" });
Assert.True(list.Contains(IPAddress.Parse("fd12:3456::1")));
Assert.False(list.Contains(IPAddress.Parse("fc00::1")));
}
[Fact]
public void Ipv4_mapped_v6_matches_v4_entry()
{
var list = IpAllowList.Parse(new[] { "127.0.0.1" });
var mapped = IPAddress.Parse("::ffff:127.0.0.1");
Assert.True(list.Contains(mapped));
}
[Fact]
public void TryParse_reports_invalid_entries()
{
var ok = IpAllowList.TryParse(new[] { "10.0.0.1", "garbage" }, out _, out var error);
Assert.False(ok);
Assert.Contains("garbage", error);
}
}
@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="xunit" Version="2.5.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\WebhookServer.Core\WebhookServer.Core.csproj" />
</ItemGroup>
</Project>