Initial WebhookServer implementation
Add the .NET 8 solution scaffolded against PLAN.md. Three projects share WebhookServer.Core (models, auth, execution, storage, IPC, callbacks) and WebhookServer.Service hosts an embedded Kestrel listener plus the named-pipe admin server. WebhookServer.Gui is a thin MVVM client over the pipe. Includes 25 unit tests covering HMAC verification, bearer auth, IP allowlist parsing, arg-template rendering, DPAPI round-trip, and the encrypt-on-save config store. Install/uninstall PowerShell scripts default to LocalSystem and accept a domain user or gMSA via -ServiceAccount. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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) },
|
||||
};
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using System.Runtime.Versioning;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Serilog;
|
||||
using WebhookServer.Core.Callbacks;
|
||||
using WebhookServer.Core.Execution;
|
||||
using WebhookServer.Core.Models;
|
||||
using WebhookServer.Core.Storage;
|
||||
using WebhookServer.Service;
|
||||
|
||||
[assembly: SupportedOSPlatform("windows")]
|
||||
|
||||
Directory.CreateDirectory(ServicePaths.DataRoot);
|
||||
Directory.CreateDirectory(ServicePaths.LogsDir);
|
||||
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.MinimumLevel.Information()
|
||||
.Enrich.FromLogContext()
|
||||
.WriteTo.Async(a => a.File(
|
||||
ServicePaths.LogFileTemplate,
|
||||
rollingInterval: RollingInterval.Day,
|
||||
retainedFileCountLimit: 14,
|
||||
outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}"))
|
||||
.CreateLogger();
|
||||
|
||||
try
|
||||
{
|
||||
Log.Information("Starting WebhookServer.Service");
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Host.UseWindowsService(o => o.ServiceName = "WebhookServer");
|
||||
builder.Host.UseSerilog();
|
||||
|
||||
var configStore = new ConfigStore(ServicePaths.ConfigPath);
|
||||
var initialConfig = await configStore.LoadAsync().ConfigureAwait(false);
|
||||
ConfigStore.DecryptSecrets(initialConfig);
|
||||
|
||||
builder.WebHost.ConfigureKestrel(opts =>
|
||||
{
|
||||
opts.ListenAnyIP(initialConfig.HttpPort);
|
||||
ConfigureHttps(opts, initialConfig.HttpsBinding);
|
||||
});
|
||||
|
||||
builder.Services.AddSingleton(configStore);
|
||||
builder.Services.AddSingleton<ServiceState>();
|
||||
builder.Services.AddSingleton<IExecutor, ProcessExecutor>();
|
||||
builder.Services.AddSingleton<ConcurrencyGate>();
|
||||
builder.Services.AddSingleton<CallbackDispatcher>(sp =>
|
||||
new CallbackDispatcher(sp.GetService<Microsoft.Extensions.Logging.ILogger<CallbackDispatcher>>()));
|
||||
builder.Services.AddSingleton<WebhookRouter>();
|
||||
builder.Services.AddHostedService<CallbackBackgroundService>();
|
||||
builder.Services.AddHostedService<AdminPipeServer>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
var state = app.Services.GetRequiredService<ServiceState>();
|
||||
await state.LoadAsync().ConfigureAwait(false);
|
||||
|
||||
var lifetime = app.Services.GetRequiredService<IHostApplicationLifetime>();
|
||||
state.ListenerSettingsChanged += (_, _) =>
|
||||
{
|
||||
Log.Information("Listener settings changed; stopping service for restart.");
|
||||
lifetime.StopApplication();
|
||||
};
|
||||
|
||||
app.MapPost("/hook/{slug}", async (string slug, HttpContext http) =>
|
||||
{
|
||||
var router = http.RequestServices.GetRequiredService<WebhookRouter>();
|
||||
await router.HandleAsync(http, slug);
|
||||
});
|
||||
|
||||
app.MapGet("/healthz", () => Results.Ok(new { ok = true }));
|
||||
|
||||
await app.RunAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Fatal(ex, "Service terminated unexpectedly");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Log.CloseAndFlush();
|
||||
}
|
||||
|
||||
static void ConfigureHttps(Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerOptions opts, HttpsBinding? binding)
|
||||
{
|
||||
if (binding is null || binding.Kind == HttpsBindingKind.None) return;
|
||||
|
||||
X509Certificate2? cert = null;
|
||||
switch (binding.Kind)
|
||||
{
|
||||
case HttpsBindingKind.PfxFile:
|
||||
if (string.IsNullOrEmpty(binding.PfxPath)) return;
|
||||
var password = binding.PfxPassword?.Plaintext;
|
||||
cert = string.IsNullOrEmpty(password)
|
||||
? new X509Certificate2(binding.PfxPath)
|
||||
: new X509Certificate2(binding.PfxPath, password);
|
||||
break;
|
||||
case HttpsBindingKind.CertStoreThumbprint:
|
||||
if (string.IsNullOrEmpty(binding.Thumbprint)) return;
|
||||
using (var store = new X509Store(StoreName.My, binding.StoreLocation))
|
||||
{
|
||||
store.Open(OpenFlags.ReadOnly);
|
||||
var matches = store.Certificates.Find(X509FindType.FindByThumbprint, binding.Thumbprint, validOnly: false);
|
||||
if (matches.Count > 0) cert = matches[0];
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (cert is null)
|
||||
{
|
||||
Log.Warning("HTTPS binding configured but no certificate was loaded; HTTPS endpoint will not be enabled.");
|
||||
return;
|
||||
}
|
||||
|
||||
opts.ListenAnyIP(binding.Port, listen => listen.UseHttps(cert));
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace WebhookServer.Service;
|
||||
|
||||
/// <summary>
|
||||
/// Standard locations for runtime files (config + logs). Centralised so they're easy
|
||||
/// to override in tests and inspect in one place.
|
||||
/// </summary>
|
||||
public static class ServicePaths
|
||||
{
|
||||
public static string DataRoot { get; } =
|
||||
Environment.GetEnvironmentVariable("WEBHOOKSERVER_DATA")
|
||||
?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "WebhookServer");
|
||||
|
||||
public static string ConfigPath => Path.Combine(DataRoot, "config.json");
|
||||
public static string LogsDir => Path.Combine(DataRoot, "logs");
|
||||
public static string LogFileTemplate => Path.Combine(LogsDir, "webhook-.log");
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
using System.Runtime.Versioning;
|
||||
using WebhookServer.Core.Auth;
|
||||
using WebhookServer.Core.Models;
|
||||
using WebhookServer.Core.Storage;
|
||||
|
||||
namespace WebhookServer.Service;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory authoritative copy of the current <see cref="ServerConfig"/>. Holds parsed
|
||||
/// helpers (allowlists keyed by endpoint id, slug → endpoint map) and notifies subscribers
|
||||
/// when the config is replaced so the listener and dispatcher can react.
|
||||
/// </summary>
|
||||
[SupportedOSPlatform("windows")]
|
||||
public sealed class ServiceState
|
||||
{
|
||||
private readonly ConfigStore _store;
|
||||
private readonly object _lock = new();
|
||||
private ServerConfig _config = new();
|
||||
private Dictionary<string, EndpointConfig> _bySlug = new(StringComparer.Ordinal);
|
||||
private Dictionary<Guid, IpAllowList> _allowLists = new();
|
||||
private IpAllowList _trustedProxies = IpAllowList.Parse(Array.Empty<string>());
|
||||
|
||||
public DateTimeOffset StartedAt { get; } = DateTimeOffset.UtcNow;
|
||||
|
||||
public event EventHandler? ListenerSettingsChanged;
|
||||
|
||||
public ServiceState(ConfigStore store)
|
||||
{
|
||||
_store = store;
|
||||
}
|
||||
|
||||
public ServerConfig Snapshot()
|
||||
{
|
||||
lock (_lock) return _config;
|
||||
}
|
||||
|
||||
public bool TryGetEndpoint(string slug, out EndpointConfig endpoint)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _bySlug.TryGetValue(slug, out endpoint!);
|
||||
}
|
||||
}
|
||||
|
||||
public IpAllowList GetAllowList(Guid endpointId)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _allowLists.TryGetValue(endpointId, out var l) ? l : IpAllowList.Parse(Array.Empty<string>());
|
||||
}
|
||||
}
|
||||
|
||||
public IpAllowList GetTrustedProxies()
|
||||
{
|
||||
lock (_lock) return _trustedProxies;
|
||||
}
|
||||
|
||||
public async Task LoadAsync(CancellationToken ct = default)
|
||||
{
|
||||
var loaded = await _store.LoadAsync(ct).ConfigureAwait(false);
|
||||
ConfigStore.DecryptSecrets(loaded);
|
||||
Replace(loaded, listenerChanged: true);
|
||||
}
|
||||
|
||||
public async Task ReplaceAsync(ServerConfig replacement, CancellationToken ct = default)
|
||||
{
|
||||
// Save to disk first; that re-encrypts secrets in place. Then publish in-memory.
|
||||
var listenerChanged = HasListenerSettingsChanged(_config, replacement);
|
||||
await _store.SaveAsync(replacement, ct).ConfigureAwait(false);
|
||||
|
||||
// SaveAsync filled in Encrypted; ensure Plaintext is populated for runtime use.
|
||||
ConfigStore.DecryptSecrets(replacement);
|
||||
Replace(replacement, listenerChanged);
|
||||
}
|
||||
|
||||
private void Replace(ServerConfig cfg, bool listenerChanged)
|
||||
{
|
||||
var bySlug = new Dictionary<string, EndpointConfig>(StringComparer.Ordinal);
|
||||
var allow = new Dictionary<Guid, IpAllowList>();
|
||||
|
||||
foreach (var ep in cfg.Endpoints)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(ep.Slug))
|
||||
bySlug[ep.Slug] = ep;
|
||||
allow[ep.Id] = IpAllowList.Parse(ep.AllowedClients);
|
||||
}
|
||||
|
||||
var trusted = IpAllowList.Parse(cfg.TrustedProxies);
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
_config = cfg;
|
||||
_bySlug = bySlug;
|
||||
_allowLists = allow;
|
||||
_trustedProxies = trusted;
|
||||
}
|
||||
|
||||
if (listenerChanged)
|
||||
ListenerSettingsChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
private static bool HasListenerSettingsChanged(ServerConfig oldCfg, ServerConfig newCfg)
|
||||
{
|
||||
if (oldCfg.HttpPort != newCfg.HttpPort) return true;
|
||||
var a = oldCfg.HttpsBinding;
|
||||
var b = newCfg.HttpsBinding;
|
||||
if ((a is null) != (b is null)) return true;
|
||||
if (a is not null && b is not null)
|
||||
{
|
||||
if (a.Kind != b.Kind || a.Port != b.Port || a.PfxPath != b.PfxPath || a.Thumbprint != b.Thumbprint)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Runtime.Versioning;
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using WebhookServer.Core.Auth;
|
||||
using WebhookServer.Core.Callbacks;
|
||||
using WebhookServer.Core.Execution;
|
||||
using WebhookServer.Core.Models;
|
||||
using ExecCtx = WebhookServer.Core.Execution.ExecutionContext;
|
||||
|
||||
namespace WebhookServer.Service;
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
public sealed class WebhookRouter
|
||||
{
|
||||
private readonly ServiceState _state;
|
||||
private readonly IExecutor _executor;
|
||||
private readonly ConcurrencyGate _gate;
|
||||
private readonly CallbackDispatcher _callbacks;
|
||||
private readonly ILogger<WebhookRouter> _logger;
|
||||
|
||||
public WebhookRouter(
|
||||
ServiceState state,
|
||||
IExecutor executor,
|
||||
ConcurrencyGate gate,
|
||||
CallbackDispatcher callbacks,
|
||||
ILogger<WebhookRouter> logger)
|
||||
{
|
||||
_state = state;
|
||||
_executor = executor;
|
||||
_gate = gate;
|
||||
_callbacks = callbacks;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task HandleAsync(HttpContext http, string slug)
|
||||
{
|
||||
var runId = Guid.NewGuid().ToString("N");
|
||||
|
||||
if (!_state.TryGetEndpoint(slug, out var endpoint) || !endpoint.Enabled)
|
||||
{
|
||||
http.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
return;
|
||||
}
|
||||
|
||||
var clientIp = ResolveClientIp(http);
|
||||
|
||||
// 1. IP allowlist (before auth, before reading body).
|
||||
var allowList = _state.GetAllowList(endpoint.Id);
|
||||
if (!allowList.IsEmpty && (clientIp is null || !allowList.Contains(clientIp)))
|
||||
{
|
||||
_logger.LogWarning("IP {Ip} blocked for endpoint {Slug} (run {RunId})", clientIp, slug, runId);
|
||||
http.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Capture raw body bytes (needed for HMAC verification and stdin/template).
|
||||
byte[] bodyBytes;
|
||||
try
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
await http.Request.Body.CopyToAsync(ms, http.RequestAborted).ConfigureAwait(false);
|
||||
bodyBytes = ms.ToArray();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed reading body for {Slug} (run {RunId})", slug, runId);
|
||||
http.Response.StatusCode = StatusCodes.Status400BadRequest;
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Auth.
|
||||
var authResult = VerifyAuth(endpoint, http, bodyBytes);
|
||||
if (!authResult.Success)
|
||||
{
|
||||
_logger.LogWarning("Auth failed for {Slug}: {Reason} (run {RunId})", slug, authResult.Reason, runId);
|
||||
http.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. Build execution context.
|
||||
var bodyString = Encoding.UTF8.GetString(bodyBytes);
|
||||
JsonNode? bodyJson = null;
|
||||
try
|
||||
{
|
||||
if (bodyBytes.Length > 0)
|
||||
bodyJson = JsonNode.Parse(bodyBytes);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Non-JSON body — leave bodyJson null so {{body.*}} renders empty.
|
||||
}
|
||||
|
||||
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var (key, value) in http.Request.Headers)
|
||||
headers[key] = value.ToString();
|
||||
|
||||
var query = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var (key, value) in http.Request.Query)
|
||||
query[key] = value.ToString();
|
||||
|
||||
var route = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) { { "slug", slug } };
|
||||
|
||||
var ctx = new ExecCtx
|
||||
{
|
||||
RunId = runId,
|
||||
Slug = slug,
|
||||
BodyBytes = bodyBytes,
|
||||
BodyString = bodyString,
|
||||
BodyJson = bodyJson,
|
||||
Headers = headers,
|
||||
Query = query,
|
||||
Route = route,
|
||||
};
|
||||
|
||||
// 5. Dispatch.
|
||||
if (endpoint.ResponseMode == ResponseMode.Async)
|
||||
{
|
||||
_ = Task.Run(() => RunAndDispatchCallbackAsync(endpoint, ctx, http.RequestAborted));
|
||||
http.Response.StatusCode = StatusCodes.Status202Accepted;
|
||||
await WriteJsonAsync(http, new { runId, accepted = true }).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var result = await RunAsync(endpoint, ctx, http.RequestAborted).ConfigureAwait(false);
|
||||
DispatchCallback(endpoint, ctx, result);
|
||||
|
||||
if (result.LaunchError is not null)
|
||||
{
|
||||
http.Response.StatusCode = StatusCodes.Status500InternalServerError;
|
||||
await WriteJsonAsync(http, new { runId, error = result.LaunchError }).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
http.Response.StatusCode = endpoint.FailOnNonZeroExit && !result.Succeeded
|
||||
? StatusCodes.Status502BadGateway
|
||||
: StatusCodes.Status200OK;
|
||||
|
||||
await WriteJsonAsync(http, new
|
||||
{
|
||||
runId,
|
||||
exitCode = result.ExitCode,
|
||||
timedOut = result.TimedOut,
|
||||
durationMs = (long)result.Duration.TotalMilliseconds,
|
||||
stdout = result.Stdout,
|
||||
stderr = result.Stderr,
|
||||
stdoutTruncated = result.StdoutTruncated,
|
||||
stderrTruncated = result.StderrTruncated,
|
||||
}).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task RunAndDispatchCallbackAsync(EndpointConfig endpoint, ExecCtx ctx, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await RunAsync(endpoint, ctx, ct).ConfigureAwait(false);
|
||||
DispatchCallback(endpoint, ctx, result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Async run failed for {Slug} (run {RunId})", ctx.Slug, ctx.RunId);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ExecutionResult> RunAsync(EndpointConfig endpoint, ExecCtx ctx, CancellationToken ct)
|
||||
{
|
||||
if (endpoint.Serialize)
|
||||
{
|
||||
using var _ = await _gate.AcquireAsync(endpoint.Id, ct).ConfigureAwait(false);
|
||||
return await _executor.RunAsync(endpoint, ctx, ct).ConfigureAwait(false);
|
||||
}
|
||||
return await _executor.RunAsync(endpoint, ctx, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private void DispatchCallback(EndpointConfig endpoint, ExecCtx ctx, ExecutionResult result)
|
||||
{
|
||||
var cb = endpoint.Callback;
|
||||
if (cb is null || string.IsNullOrEmpty(cb.Url)) return;
|
||||
|
||||
var trigger = cb.Trigger;
|
||||
var fire = trigger switch
|
||||
{
|
||||
CallbackTrigger.OnSuccess => result.Succeeded,
|
||||
CallbackTrigger.OnFailure => !result.Succeeded,
|
||||
_ => true,
|
||||
};
|
||||
if (!fire) return;
|
||||
|
||||
var stdout = TruncateBytes(result.Stdout, cb.MaxOutputBytes, out var stdoutCut);
|
||||
var stderr = TruncateBytes(result.Stderr, cb.MaxOutputBytes, out var stderrCut);
|
||||
|
||||
var payload = new CallbackPayload
|
||||
{
|
||||
RunId = ctx.RunId,
|
||||
Endpoint = ctx.Slug,
|
||||
StartedAt = result.StartedAt,
|
||||
CompletedAt = result.CompletedAt,
|
||||
DurationMs = (long)result.Duration.TotalMilliseconds,
|
||||
ExitCode = result.ExitCode,
|
||||
Succeeded = result.Succeeded,
|
||||
TimedOut = result.TimedOut,
|
||||
Stdout = stdout,
|
||||
Stderr = stderr,
|
||||
StdoutTruncated = result.StdoutTruncated || stdoutCut,
|
||||
StderrTruncated = result.StderrTruncated || stderrCut,
|
||||
};
|
||||
|
||||
_callbacks.Enqueue(new CallbackEnvelope
|
||||
{
|
||||
EndpointId = endpoint.Id,
|
||||
EndpointSlug = ctx.Slug,
|
||||
Config = cb,
|
||||
Payload = payload,
|
||||
});
|
||||
}
|
||||
|
||||
private static string TruncateBytes(string s, int maxBytes, out bool truncated)
|
||||
{
|
||||
truncated = false;
|
||||
if (string.IsNullOrEmpty(s)) return s;
|
||||
if (maxBytes <= 0) { truncated = true; return ""; }
|
||||
var bytes = Encoding.UTF8.GetByteCount(s);
|
||||
if (bytes <= maxBytes) return s;
|
||||
|
||||
// Trim from the end until under cap. Cheap and good enough.
|
||||
var bs = Encoding.UTF8.GetBytes(s);
|
||||
truncated = true;
|
||||
return Encoding.UTF8.GetString(bs.AsSpan(0, maxBytes));
|
||||
}
|
||||
|
||||
private static async Task WriteJsonAsync(HttpContext http, object payload)
|
||||
{
|
||||
http.Response.ContentType = "application/json; charset=utf-8";
|
||||
await System.Text.Json.JsonSerializer.SerializeAsync(http.Response.Body, payload, options: new() { PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase }, http.RequestAborted).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private AuthResult VerifyAuth(EndpointConfig endpoint, HttpContext http, byte[] body)
|
||||
{
|
||||
switch (endpoint.AuthMode)
|
||||
{
|
||||
case AuthMode.None:
|
||||
return AuthResult.Ok();
|
||||
case AuthMode.Bearer:
|
||||
var token = endpoint.Bearer?.Secret.Plaintext ?? "";
|
||||
return BearerVerifier.Verify(http.Request.Headers.Authorization.ToString(), token);
|
||||
case AuthMode.Hmac:
|
||||
if (endpoint.Hmac is null) return AuthResult.Fail("HMAC config missing");
|
||||
var headerName = endpoint.Hmac.HeaderName;
|
||||
var presented = http.Request.Headers.TryGetValue(headerName, out var v) ? v.ToString() : null;
|
||||
return HmacVerifier.Verify(body, presented, endpoint.Hmac);
|
||||
default:
|
||||
return AuthResult.Fail("unknown auth mode");
|
||||
}
|
||||
}
|
||||
|
||||
private IPAddress? ResolveClientIp(HttpContext http)
|
||||
{
|
||||
var direct = http.Connection.RemoteIpAddress;
|
||||
if (direct is null) return null;
|
||||
|
||||
var trustedProxies = _state.GetTrustedProxies();
|
||||
if (trustedProxies.IsEmpty || !trustedProxies.Contains(direct))
|
||||
return Normalize(direct);
|
||||
|
||||
// Direct hop is a trusted proxy — honor X-Forwarded-For (leftmost).
|
||||
if (http.Request.Headers.TryGetValue("X-Forwarded-For", out var xff) && !string.IsNullOrEmpty(xff))
|
||||
{
|
||||
var first = xff.ToString().Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).FirstOrDefault();
|
||||
if (!string.IsNullOrEmpty(first) && IPAddress.TryParse(first, out var parsed))
|
||||
return Normalize(parsed);
|
||||
}
|
||||
|
||||
return Normalize(direct);
|
||||
}
|
||||
|
||||
private static IPAddress Normalize(IPAddress address)
|
||||
{
|
||||
if (address.AddressFamily == AddressFamily.InterNetworkV6 && address.IsIPv4MappedToIPv6)
|
||||
return address.MapToIPv4();
|
||||
return address;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UserSecretsId>dotnet-WebhookServer.Service-57f4579b-6131-4fab-a6ad-2865b038cc2e</UserSecretsId>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="8.0.1" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="8.0.3" />
|
||||
<PackageReference Include="Serilog.Sinks.Async" Version="2.1.0" />
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\WebhookServer.Core\WebhookServer.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.Hosting.Lifetime": "Information"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user