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,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; }
}
+55
View File
@@ -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();
}