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