Initial WebhookServer implementation

Add the .NET 8 solution scaffolded against PLAN.md. Three projects share
WebhookServer.Core (models, auth, execution, storage, IPC, callbacks)
and WebhookServer.Service hosts an embedded Kestrel listener plus the
named-pipe admin server. WebhookServer.Gui is a thin MVVM client over
the pipe. Includes 25 unit tests covering HMAC verification, bearer
auth, IP allowlist parsing, arg-template rendering, DPAPI round-trip,
and the encrypt-on-save config store.

Install/uninstall PowerShell scripts default to LocalSystem and accept
a domain user or gMSA via -ServiceAccount.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-07 22:04:52 -04:00
parent 2f61b342af
commit 8ecfe84540
62 changed files with 3721 additions and 0 deletions
@@ -0,0 +1,74 @@
using System.Text;
using System.Text.Json.Nodes;
using WebhookServer.Core.Execution;
using ExecCtx = WebhookServer.Core.Execution.ExecutionContext;
using Xunit;
namespace WebhookServer.Core.Tests;
public class ArgTemplateRendererTests
{
private static ExecCtx Ctx(string body, Dictionary<string, string>? headers = null, Dictionary<string, string>? query = null)
{
var bytes = Encoding.UTF8.GetBytes(body);
return new ExecCtx
{
RunId = "r",
Slug = "s",
BodyBytes = bytes,
BodyString = body,
BodyJson = body.Length == 0 ? null : JsonNode.Parse(body),
Headers = headers ?? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase),
Query = query ?? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase),
Route = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) { { "slug", "deploy" } },
};
}
[Fact]
public void Whitespace_separated_tokens_become_separate_args()
{
var ctx = Ctx("{\"name\":\"alice\",\"id\":7}");
var args = ArgTemplateRenderer.Render("{{body.name}} {{body.id}}", ctx);
Assert.Equal(new[] { "alice", "7" }, args);
}
[Fact]
public void Header_lookup_is_case_insensitive()
{
var ctx = Ctx("", headers: new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) { ["X-GitHub-Event"] = "push" });
var args = ArgTemplateRenderer.Render("{{header.x-github-event}}", ctx);
Assert.Equal(new[] { "push" }, args);
}
[Fact]
public void Missing_path_renders_empty_string()
{
var ctx = Ctx("{}");
var args = ArgTemplateRenderer.Render("{{body.nope}}", ctx);
Assert.Equal(new[] { "" }, args);
}
[Fact]
public void Route_value_resolves()
{
var ctx = Ctx("");
var args = ArgTemplateRenderer.Render("{{route.slug}}", ctx);
Assert.Equal(new[] { "deploy" }, args);
}
[Fact]
public void Multiple_substitutions_in_one_token_are_concatenated()
{
var ctx = Ctx("{\"a\":\"x\",\"b\":\"y\"}");
var args = ArgTemplateRenderer.Render("{{body.a}}-{{body.b}}", ctx);
Assert.Equal(new[] { "x-y" }, args);
}
[Fact]
public void Nested_json_path_resolves()
{
var ctx = Ctx("{\"repo\":{\"name\":\"acme\"}}");
var args = ArgTemplateRenderer.Render("{{body.repo.name}}", ctx);
Assert.Equal(new[] { "acme" }, args);
}
}
@@ -0,0 +1,27 @@
using WebhookServer.Core.Auth;
using Xunit;
namespace WebhookServer.Core.Tests;
public class BearerVerifierTests
{
[Fact]
public void Accepts_correct_token() =>
Assert.True(BearerVerifier.Verify("Bearer s3cret", "s3cret").Success);
[Fact]
public void Rejects_wrong_token() =>
Assert.False(BearerVerifier.Verify("Bearer nope", "s3cret").Success);
[Fact]
public void Rejects_missing_header() =>
Assert.False(BearerVerifier.Verify(null, "s3cret").Success);
[Fact]
public void Rejects_non_bearer_scheme() =>
Assert.False(BearerVerifier.Verify("Basic s3cret", "s3cret").Success);
[Fact]
public void Rejects_when_server_secret_empty() =>
Assert.False(BearerVerifier.Verify("Bearer s3cret", "").Success);
}
@@ -0,0 +1,52 @@
using System.Runtime.InteropServices;
using WebhookServer.Core.Models;
using WebhookServer.Core.Storage;
using Xunit;
namespace WebhookServer.Core.Tests;
public class ConfigStoreTests
{
[Fact]
public async Task Save_then_load_preserves_endpoints_and_encrypts_secrets()
{
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return;
var path = Path.Combine(Path.GetTempPath(), $"webhook-test-{Guid.NewGuid():N}.json");
try
{
var store = new ConfigStore(path);
var cfg = new ServerConfig
{
HttpPort = 9000,
Endpoints =
{
new EndpointConfig
{
Slug = "deploy",
AuthMode = AuthMode.Bearer,
Bearer = new BearerOptions { Secret = ProtectedString.FromPlaintext("topsecret") },
},
},
};
await store.SaveAsync(cfg);
// Persisted config must not contain plaintext.
var rawJson = await File.ReadAllTextAsync(path);
Assert.DoesNotContain("topsecret", rawJson);
Assert.Contains("encrypted", rawJson);
var reloaded = await store.LoadAsync();
ConfigStore.DecryptSecrets(reloaded);
var ep = Assert.Single(reloaded.Endpoints);
Assert.Equal("deploy", ep.Slug);
Assert.Equal("topsecret", ep.Bearer!.Secret.Plaintext);
}
finally
{
if (File.Exists(path)) File.Delete(path);
}
}
}
@@ -0,0 +1,27 @@
using System.Runtime.InteropServices;
using WebhookServer.Core.Storage;
using Xunit;
namespace WebhookServer.Core.Tests;
public class DpapiSecretTests
{
[Fact]
public void Round_trip_recovers_original_value()
{
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return;
var original = "topsecret-é-🚀";
var encrypted = DpapiSecret.Protect(original);
Assert.NotEmpty(encrypted);
var decrypted = DpapiSecret.Unprotect(encrypted);
Assert.Equal(original, decrypted);
}
[Fact]
public void Empty_string_round_trips_as_empty()
{
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return;
Assert.Equal("", DpapiSecret.Unprotect(DpapiSecret.Protect("")));
}
}
@@ -0,0 +1,79 @@
using System.Security.Cryptography;
using System.Text;
using WebhookServer.Core.Auth;
using WebhookServer.Core.Models;
using Xunit;
namespace WebhookServer.Core.Tests;
public class HmacVerifierTests
{
[Fact]
public void Compute_matches_GitHub_style_signature()
{
var body = Encoding.UTF8.GetBytes("{\"x\":1}");
var secret = "topsecret";
var hex = HmacVerifier.Compute(body, secret, HmacAlgorithm.Sha256, HmacEncoding.Hex);
// Cross-check against direct HMACSHA256 to ensure no encoding drift.
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var expected = Convert.ToHexString(hmac.ComputeHash(body)).ToLowerInvariant();
Assert.Equal(expected, hex);
}
[Fact]
public void Verify_accepts_correct_signature_with_prefix()
{
var body = Encoding.UTF8.GetBytes("hello world");
var secret = "shhh";
var sig = HmacVerifier.Compute(body, secret, HmacAlgorithm.Sha256, HmacEncoding.Hex);
var options = new HmacOptions { Secret = ProtectedString.FromPlaintext(secret) };
var result = HmacVerifier.Verify(body, $"sha256={sig}", options);
Assert.True(result.Success);
}
[Fact]
public void Verify_rejects_wrong_signature()
{
var body = Encoding.UTF8.GetBytes("payload");
var options = new HmacOptions { Secret = ProtectedString.FromPlaintext("right") };
var sig = HmacVerifier.Compute(body, "wrong", HmacAlgorithm.Sha256, HmacEncoding.Hex);
var result = HmacVerifier.Verify(body, $"sha256={sig}", options);
Assert.False(result.Success);
}
[Fact]
public void Verify_rejects_when_prefix_missing()
{
var body = Encoding.UTF8.GetBytes("payload");
var options = new HmacOptions { Secret = ProtectedString.FromPlaintext("k") };
var sig = HmacVerifier.Compute(body, "k", HmacAlgorithm.Sha256, HmacEncoding.Hex);
var result = HmacVerifier.Verify(body, sig, options); // no "sha256=" prefix
Assert.False(result.Success);
}
[Fact]
public void Verify_handles_base64_encoding()
{
var body = Encoding.UTF8.GetBytes("payload");
var secret = "abc";
var sig = HmacVerifier.Compute(body, secret, HmacAlgorithm.Sha256, HmacEncoding.Base64);
var options = new HmacOptions
{
Encoding = HmacEncoding.Base64,
Prefix = "",
Secret = ProtectedString.FromPlaintext(secret),
};
var result = HmacVerifier.Verify(body, sig, options);
Assert.True(result.Success);
}
}
@@ -0,0 +1,57 @@
using System.Net;
using WebhookServer.Core.Auth;
using Xunit;
namespace WebhookServer.Core.Tests;
public class IpAllowListTests
{
[Fact]
public void Empty_list_allows_everything()
{
var list = IpAllowList.Parse(Array.Empty<string>());
Assert.True(list.IsEmpty);
Assert.True(list.Contains(IPAddress.Parse("1.2.3.4")));
Assert.True(list.Contains(IPAddress.Parse("::1")));
}
[Fact]
public void Single_v4_matches_exact_only()
{
var list = IpAllowList.Parse(new[] { "192.168.1.10" });
Assert.True(list.Contains(IPAddress.Parse("192.168.1.10")));
Assert.False(list.Contains(IPAddress.Parse("192.168.1.11")));
}
[Fact]
public void V4_cidr_matches_inside_range()
{
var list = IpAllowList.Parse(new[] { "10.0.0.0/8" });
Assert.True(list.Contains(IPAddress.Parse("10.10.1.42")));
Assert.False(list.Contains(IPAddress.Parse("11.0.0.1")));
}
[Fact]
public void V6_cidr_matches_inside_range()
{
var list = IpAllowList.Parse(new[] { "fd00::/8" });
Assert.True(list.Contains(IPAddress.Parse("fd12:3456::1")));
Assert.False(list.Contains(IPAddress.Parse("fc00::1")));
}
[Fact]
public void Ipv4_mapped_v6_matches_v4_entry()
{
var list = IpAllowList.Parse(new[] { "127.0.0.1" });
var mapped = IPAddress.Parse("::ffff:127.0.0.1");
Assert.True(list.Contains(mapped));
}
[Fact]
public void TryParse_reports_invalid_entries()
{
var ok = IpAllowList.TryParse(new[] { "10.0.0.1", "garbage" }, out _, out var error);
Assert.False(ok);
Assert.Contains("garbage", error);
}
}
@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="xunit" Version="2.5.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\WebhookServer.Core\WebhookServer.Core.csproj" />
</ItemGroup>
</Project>