Empty project scaffolded with the approved implementation plan, README overview, and a .NET-appropriate .gitignore. Implementation will follow on a Windows machine. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
17 KiB
Windows Webhook Server — Implementation Plan
Context
Greenfield project at /Users/justin/GitHub/webhook-server (currently empty). Goal: a Windows-native webhook server that receives HTTP requests and runs PowerShell, PowerShell Core, cmd/.bat, or arbitrary executables in response. Each webhook is configured in a desktop GUI; the actual server runs as a Windows Service so it survives reboots and works without anyone logged in. Auth is per-endpoint (HMAC, bearer, or none) so it can sit behind a CI system, a smart-home hub, GitHub webhooks, etc.
Note: Build/test machine must be Windows. The current dev host is macOS — final verification needs a Windows VM or box.
Architecture summary
Two processes, one config:
┌──────────────────┐ named pipe ┌──────────────────────────────┐
│ WPF GUI app │ ◄──────────► │ Windows Service │
│ (config/monitor)│ │ ├─ Kestrel: webhook listener│
└──────────────────┘ │ ├─ Named-pipe admin server │
│ ├─ Executor pool │
│ └─ Serilog file logging │
└──────────────────────────────┘
▲
C:\ProgramData\WebhookServer\
├─ config.json (DPAPI-encrypted secrets)
└─ logs\*.log
WebhookServer.Service— .NET 8 Worker Service. Hosts an embedded KestrelWebApplicationfor webhook traffic, plus a named-pipe server for the GUI. Single source of truth.WebhookServer.Gui— WPF (.NET 8) MVVM app. Thin client over the named pipe. Edits endpoints, shows live status/logs, can start/stop the service.WebhookServer.Core— class library shared by both. Config schema, executor abstraction, auth verifiers, IPC contracts, DPAPI helpers.
Tech choices (locked from clarifying Qs)
| Concern | Choice |
|---|---|
| Stack | C# / .NET 8 + WPF |
| Endpoints | Multiple, each at its own URL slug |
| Auth | Per-endpoint pick: HMAC / bearer token / none |
| Run mode | Service-first; GUI is config/monitor only |
| Script types | Windows PowerShell, PowerShell Core, cmd/.bat, arbitrary exe |
| Data passing | Any combination of: JSON body → stdin, query/headers → env vars, arg template {{...}} |
| Response | Per-endpoint sync (exit code + stdout/stderr) or async (202 immediate) |
| GUI ↔ service | Named pipe \\.\pipe\WebhookServerAdmin (JSON over line-delimited frames) |
| HTTPS | HTTP default; GUI can bind a cert (.pfx path or cert-store thumbprint) |
| Secrets | DPAPI LocalMachine scope — encrypted at rest in config.json |
| Concurrency | Parallel by default; per-endpoint "serialize" flag forces a queue |
| IP allowlist | Per-endpoint list of IPs and CIDR subnets; empty = all allowed; checked before auth |
Solution layout
webhook-server/
├─ WebhookServer.sln
├─ src/
│ ├─ WebhookServer.Core/ (netstandard2.0 or net8.0 class lib)
│ │ ├─ Models/
│ │ │ ├─ EndpointConfig.cs
│ │ │ ├─ AuthMode.cs (None | Bearer | Hmac)
│ │ │ ├─ ExecutorType.cs (WindowsPowerShell | PwshCore | Cmd | Executable)
│ │ │ ├─ ResponseMode.cs (Sync | Async)
│ │ │ ├─ DataPassingOptions.cs (StdinJson, EnvVars, ArgTemplate flags + template string)
│ │ │ ├─ AllowedClient.cs (single IP or CIDR string, validated)
│ │ │ └─ ServerConfig.cs (HttpPort, HttpsBinding, TrustedProxies[], AdminToken, Endpoints[])
│ │ ├─ Auth/
│ │ │ ├─ IAuthVerifier.cs
│ │ │ ├─ BearerVerifier.cs
│ │ │ ├─ HmacVerifier.cs (configurable header + sha1/sha256/sha512)
│ │ │ └─ IpAllowList.cs (parse + match IP / CIDR, IPv4 + IPv6)
│ │ ├─ Execution/
│ │ │ ├─ IExecutor.cs (RunAsync(ctx) → ExecutionResult)
│ │ │ ├─ ProcessExecutor.cs (single impl, varies argv per ExecutorType)
│ │ │ ├─ ArgTemplateRenderer.cs ({{body.x}}, {{header.X-Foo}}, {{query.bar}})
│ │ │ └─ ConcurrencyGate.cs (per-endpoint SemaphoreSlim when serialize=true)
│ │ ├─ Storage/
│ │ │ ├─ ConfigStore.cs (load/save JSON, atomic write)
│ │ │ └─ DpapiSecret.cs (Protect/Unprotect LocalMachine)
│ │ └─ Ipc/
│ │ ├─ AdminProtocol.cs (request/response DTOs)
│ │ └─ PipeSecurityFactory.cs (SYSTEM + Administrators ACL)
│ ├─ WebhookServer.Service/ (.NET 8 Worker)
│ │ ├─ Program.cs (Host.CreateApplicationBuilder + UseWindowsService)
│ │ ├─ WebhookHost.cs (BackgroundService that owns the WebApplication)
│ │ ├─ WebhookRouter.cs (maps slug → endpoint, runs auth, builds context, dispatches)
│ │ ├─ AdminPipeServer.cs (BackgroundService listening on the named pipe)
│ │ └─ appsettings.json (only logging defaults — no endpoint config here)
│ └─ WebhookServer.Gui/ (WPF, .NET 8)
│ ├─ App.xaml(.cs)
│ ├─ MainWindow.xaml(.cs) (endpoint list + log tail)
│ ├─ Views/EndpointEditor.xaml (per-endpoint edit form)
│ ├─ Views/ServerSettings.xaml (port, HTTPS cert, install/uninstall service)
│ ├─ ViewModels/ (CommunityToolkit.Mvvm)
│ └─ Services/AdminPipeClient.cs (talks to AdminPipeServer)
└─ scripts/
├─ install-service.ps1 (sc.exe create WebhookServer binPath= ... start= auto)
└─ uninstall-service.ps1
Key NuGet packages
Microsoft.Extensions.Hosting,Microsoft.Extensions.Hosting.WindowsServicesMicrosoft.AspNetCore.Appframework reference (Kestrel + minimal API routing)System.Security.Cryptography.ProtectedData(DPAPI)Serilog.AspNetCore,Serilog.Sinks.File,Serilog.Sinks.AsyncCommunityToolkit.Mvvm(GUI)
Webhook request flow
- Kestrel receives
POST /hook/{slug}. - Router looks up endpoint by slug. 404 if missing or disabled.
- IP allowlist check (before any expensive work):
- Resolve the effective client IP:
HttpContext.Connection.RemoteIpAddress, then if that IP is in the server-levelTrustedProxieslist, take the leftmost entry fromX-Forwarded-Forinstead. DefaultTrustedProxiesis empty, so by defaultX-Forwarded-Foris ignored. - If the endpoint's
AllowedClientslist is non-empty and the effective IP doesn't match any entry → 403 (no body, log the rejection). - Empty
AllowedClients= allow all.
- Resolve the effective client IP:
- Capture raw body bytes (needed for HMAC). Buffer with
EnableBuffering()or read tobyte[]. - Run the endpoint's auth verifier:
None→ pass.Bearer→ compareAuthorization: Bearer <secret>(constant-time compare).Hmac→ compute HMAC of raw body with configured algo + secret, compare against configured header (defaultX-Hub-Signature-256). Constant-time compare.- Fail → 401.
- Build
ExecutionContext:{ body (string + JsonNode), headers, query, route }. - Acquire concurrency gate (no-op if not serialized).
- Build
ProcessStartInfo:WindowsPowerShell→powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "<path>"or-Command "<inline>".PwshCore→pwsh.exe ...(same flags).Cmd→cmd.exe /c "<command-or-batfile>".Executable→ user-supplied exe path + args.- Apply data-passing options (any combination):
StdinJson→ write request body to stdin, close stdin.EnvVars→ for each query paramWEBHOOK_QUERY_<KEY>=value; for each headerWEBHOOK_HEADER_<KEY>=value(sanitize key chars).ArgTemplate→ render template, append rendered tokens to argv.
- Dispatch:
- Sync mode: await process exit (with
Timeoutcancellation token). Return200(or502on non-zero exit, configurable) with body{ exitCode, stdout, stderr, durationMs }. - Async mode: fire-and-forget Task; return
202 Acceptedwith{ runId }. Output goes to log + visible in GUI.
- Sync mode: await process exit (with
- Always log: timestamp, slug, effective client IP, IP-allowlist result, auth result, exit code, duration, stdout/stderr (truncated). Serilog → daily rolling file.
Argument template syntax
Simple {{path}} substitution. Path grammar:
{{body.foo.bar}}— JSON path into body (usesJsonNode).{{header.X-GitHub-Event}}— header by name (case-insensitive).{{query.ref}}— query param.{{route.slug}}— route values.
Missing paths render as empty string. Each {{...}} becomes one argv token (already-quoted handling done by ProcessStartInfo.ArgumentList). No expression evaluation — keep it dumb so it can't be a sandbox escape vector.
IP allowlist details
Each endpoint has an AllowedClients: string[] field. Each entry is one of:
- Single IPv4 address —
192.168.50.20 - Single IPv6 address —
fe80::1 - IPv4 CIDR —
10.10.1.0/24 - IPv6 CIDR —
fd00::/8
Empty list = allow all (matches the user's default-open requirement). Any non-empty list switches that endpoint to deny-by-default.
Implementation:
IpAllowListparses entries on config load; validation errors are surfaced in the GUI before save.- Use
System.Net.IPNetwork(added in .NET 8) for CIDR parse +Contains(IPAddress)matching. No third-party lib needed. - Match IPv4-mapped IPv6 (
::ffff:1.2.3.4) against IPv4 entries by normalizing withIPAddress.MapToIPv4()before the check. - A server-level
TrustedProxieslist (also IPs/CIDRs) controls whetherX-Forwarded-For/X-Real-IPis honored. If the direct connection comes from a trusted proxy, walkX-Forwarded-Forfrom rightmost-trusted leftward and use the first untrusted IP. Otherwise ignore forwarded headers — important so callers can't spoof their IP by adding a header. - Default
TrustedProxiesis empty (most secure). GUI surfaces it under server settings with a note about reverse-proxy setups. - Rejections are logged and counted per-endpoint so the GUI can surface "blocked: 47 in last hour" — useful for catching misconfigured callers.
Order in the request pipeline matters: IP check runs before auth. That avoids HMAC compute work on blocked IPs and prevents any timing-based information leak about token validity to non-allowed sources.
Auth details
- Bearer: secret is the token itself. Verify
Authorization: Bearer <secret>. Fixed-timeCryptographicOperations.FixedTimeEquals. - HMAC: configurable per endpoint:
Algorithm: SHA1 / SHA256 (default) / SHA512HeaderName: defaultX-Hub-Signature-256Prefix: defaultsha256=(stripped before compare)Encoding: hex (default) or base64- Compute HMAC over raw body bytes with secret. Fixed-time compare.
This covers GitHub, Stripe, Slack, generic CI patterns by tweaking the four fields.
Secret storage (DPAPI)
Endpoint Secret is stored in JSON as { "encrypted": "<base64 of ProtectedData.Protect(utf8(secret), null, LocalMachine)>" }. Decrypt only inside the service when needed. The GUI submits secrets in plaintext over the named pipe (local-machine, ACL-restricted), service encrypts before writing.
Caveat to call out in the GUI: DPAPI LocalMachine ties config to the machine — backing up config to another box won't decrypt. Document Export Config in GUI later as a future feature.
GUI ↔ Service IPC
Named pipe \\.\pipe\WebhookServerAdmin, ACL: NT AUTHORITY\SYSTEM + BUILTIN\Administrators full control, deny everyone else. (GUI must run elevated — note that in the README.)
Protocol: line-delimited JSON request/response.
// request
{ "op": "list-endpoints" }
// response
{ "ok": true, "endpoints": [...] }
Ops needed: get-config, update-config, list-endpoints, create-endpoint, update-endpoint, delete-endpoint, enable/disable-endpoint, tail-logs (streaming), get-status, bind-https, restart-listener.
HTTPS binding
GUI offers two ways to provide a cert:
- Path to
.pfx+ password (password DPAPI-encrypted in config). - Local cert-store thumbprint (
CurrentUser\MyorLocalMachine\My).
Service builds Kestrel with both an HTTP and HTTPS endpoint when bound. Restarting the listener picks up new bindings without restarting the whole service.
Critical files to be created
src/WebhookServer.Core/Models/EndpointConfig.cs— central data shapesrc/WebhookServer.Core/Auth/HmacVerifier.cs— must use raw body + fixed-time comparesrc/WebhookServer.Core/Auth/IpAllowList.cs— IPv4/IPv6 + CIDR matching, IPv4-mapped IPv6 normalization, runs before authsrc/WebhookServer.Core/Execution/ProcessExecutor.cs— argv assembly perExecutorType, stdin/env wiring, timeoutsrc/WebhookServer.Core/Storage/DpapiSecret.cs—LocalMachinescope, base64 wire formatsrc/WebhookServer.Core/Ipc/PipeSecurityFactory.cs— correct ACL or the GUI will fail silently for non-adminssrc/WebhookServer.Service/WebhookHost.cs— Kestrel setup withEnableBuffering()for body capturesrc/WebhookServer.Service/WebhookRouter.cs— auth → context → dispatch pipelinesrc/WebhookServer.Gui/Views/EndpointEditor.xaml— the main UX surface; must make adding a "run this script when called" hook feel obvious
Verification (end-to-end)
Run on a Windows machine after dotnet publish -c Release:
- Install service:
sc.exe create WebhookServer binPath= "C:\path\to\WebhookServer.Service.exe" start= autosc.exe start WebhookServer - Launch GUI (as Administrator). Confirm it connects to the pipe; the status indicator should show "running".
- Smoke test (no auth, sync, PowerShell inline):
- Endpoint slug
/hook/ping, executor PowerShell, command'pong'. curl http://localhost:8080/hook/ping→ body containspong, exit code 0.
- Endpoint slug
- Bearer auth:
- Set bearer secret
s3cret. curl http://localhost:8080/hook/ping→ 401.curl -H "Authorization: Bearer s3cret" http://localhost:8080/hook/ping→ 200.
- Set bearer secret
- HMAC auth (GitHub-style):
- Algo SHA256, header
X-Hub-Signature-256, prefixsha256=, secrettopsecret. BODY='{"x":1}'; SIG=$(printf %s "$BODY" | openssl dgst -sha256 -hmac topsecret -hex | awk '{print $2}')curl -H "X-Hub-Signature-256: sha256=$SIG" -d "$BODY" http://localhost:8080/hook/foo→ 200.- Wrong sig → 401.
- Algo SHA256, header
- Stdin JSON + arg template:
- PowerShell script:
$j = $input | ConvertFrom-Json; "got repo=$($args[0]) name=$($j.name)" - Arg template:
{{body.repo}}. POST{"repo":"acme","name":"bob"}→ output containsgot repo=acme name=bob.
- PowerShell script:
- Env-var passing:
- cmd one-liner:
echo event=%WEBHOOK_HEADER_X_GITHUB_EVENT% curl -H "X-GitHub-Event: push" ...→ output containsevent=push.
- cmd one-liner:
- Async endpoint:
- PowerShell:
Start-Sleep 10; "done". Mode = Async. curl ...returns 202 immediately. GUI log showsdone~10s later.
- PowerShell:
- Concurrency:
- Endpoint with serialize=true and a
Start-Sleep 5script. Fire 3 parallel curls → durations ~5s, ~10s, ~15s. 9a. IP allowlist: - Set
AllowedClients = ["127.0.0.1"].curl http://localhost:8080/hook/ping→ 200; from another machine on LAN → 403. - Set
AllowedClients = ["10.10.1.0/24"]. Request from10.10.1.42→ 200; from10.10.2.5→ 403. - Empty list → all IPs allowed (regression check).
- With
TrustedProxiesempty, sendX-Forwarded-For: 127.0.0.1from a non-allowed IP → still 403 (header not trusted). - With
TrustedProxies = ["10.10.1.5"]and request coming from that IP carryingX-Forwarded-For: 192.168.50.20, allowlist["192.168.50.20"]→ 200.
- Endpoint with serialize=true and a
- HTTPS:
- In GUI, bind a self-signed
.pfx(New-SelfSignedCertificate ... -CertStoreLocation Cert:\LocalMachine\My). curl -k https://localhost:8443/hook/ping→ 200.
- In GUI, bind a self-signed
- Reboot test:
- Reboot. Verify service auto-starts and an existing endpoint still answers without launching the GUI.
- Permissions test:
- Run GUI as a non-admin user → should fail to connect to the named pipe with a clear error (not a hang).
Out of scope for v1 (call out in README)
- Importing/exporting config across machines (DPAPI-LocalMachine prevents this).
- Outbound webhook delivery / retry queues.
- Per-endpoint rate limiting.
- Multi-user RBAC for the GUI.
- Auto-update.