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,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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user