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