Files
webhook-server/PLAN.md
T
justin 920c3d8916 Add outbound callback design to plan
Per-endpoint optional callback URL: service POSTs run result after async
runs (and optionally sync). Reuses inbound HMAC code path for outbound
signing. No caller-supplied URLs (SSRF risk). Bounded queue, exponential
backoff with jitter, configurable retries.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 00:32:20 -04:00

21 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 Kestrel WebApplication for 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
Outbound callbacks Optional per-endpoint callback URL; service POSTs run result after async runs (and optionally after sync). Pre-configured only — no caller-supplied URLs.

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)
│  │   ├─ Callbacks/
│  │   │   ├─ CallbackConfig.cs      (Url, Method, AuthMode, Secret, retry/timeout)
│  │   │   ├─ CallbackDispatcher.cs  (background queue, retry w/ exponential backoff + jitter)
│  │   │   └─ CallbackPayload.cs     (runId, endpoint, exitCode, stdout/stderr, timings)
│  │   ├─ 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.WindowsServices
  • Microsoft.AspNetCore.App framework reference (Kestrel + minimal API routing)
  • System.Security.Cryptography.ProtectedData (DPAPI)
  • Serilog.AspNetCore, Serilog.Sinks.File, Serilog.Sinks.Async
  • CommunityToolkit.Mvvm (GUI)

Webhook request flow

  1. Kestrel receives POST /hook/{slug}.
  2. Router looks up endpoint by slug. 404 if missing or disabled.
  3. IP allowlist check (before any expensive work):
    • Resolve the effective client IP: HttpContext.Connection.RemoteIpAddress, then if that IP is in the server-level TrustedProxies list, take the leftmost entry from X-Forwarded-For instead. Default TrustedProxies is empty, so by default X-Forwarded-For is ignored.
    • If the endpoint's AllowedClients list is non-empty and the effective IP doesn't match any entry → 403 (no body, log the rejection).
    • Empty AllowedClients = allow all.
  4. Capture raw body bytes (needed for HMAC). Buffer with EnableBuffering() or read to byte[].
  5. Run the endpoint's auth verifier:
    • None → pass.
    • Bearer → compare Authorization: Bearer <secret> (constant-time compare).
    • Hmac → compute HMAC of raw body with configured algo + secret, compare against configured header (default X-Hub-Signature-256). Constant-time compare.
    • Fail → 401.
  6. Build ExecutionContext: { body (string + JsonNode), headers, query, route }.
  7. Acquire concurrency gate (no-op if not serialized).
  8. Build ProcessStartInfo:
    • WindowsPowerShellpowershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "<path>" or -Command "<inline>".
    • PwshCorepwsh.exe ... (same flags).
    • Cmdcmd.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 param WEBHOOK_QUERY_<KEY>=value; for each header WEBHOOK_HEADER_<KEY>=value (sanitize key chars).
      • ArgTemplate → render template, append rendered tokens to argv.
  9. Dispatch:
    • Sync mode: await process exit (with Timeout cancellation token). Return 200 (or 502 on non-zero exit, configurable) with body { exitCode, stdout, stderr, durationMs }.
    • Async mode: fire-and-forget Task; return 202 Accepted with { runId }. Output goes to log + visible in GUI.
  10. Outbound callback (if configured for this endpoint): enqueue a CallbackPayload to CallbackDispatcher. For async endpoints this is the only way the original caller can learn the result. For sync endpoints it's optional (off by default) and useful for fan-out / audit sinks.
  11. Always log: timestamp, slug, effective client IP, IP-allowlist result, auth result, exit code, duration, stdout/stderr (truncated), callback delivery status. Serilog → daily rolling file.

Argument template syntax

Simple {{path}} substitution. Path grammar:

  • {{body.foo.bar}} — JSON path into body (uses JsonNode).
  • {{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.

Outbound callbacks

Each endpoint optionally has a Callback block. When present, the service POSTs the run result to a pre-configured URL after the script finishes. Required for async endpoints if the caller wants to know what happened; optional for sync endpoints (where the result is already returned in the HTTP response).

CallbackConfig fields:

  • Url — full URL the dispatcher POSTs to.
  • Method — default POST (allow PUT for systems that prefer it).
  • AuthModeNone | Bearer | Hmac. Mirrors the inbound auth design — same code paths reused.
  • Secret — DPAPI-encrypted (bearer token, or HMAC shared secret).
  • HmacAlgorithm / HmacHeaderName / HmacPrefix / HmacEncoding — when AuthMode = Hmac. Defaults match the inbound HMAC defaults so a sender that accepts GitHub-style signatures works out of the box.
  • TimeoutSeconds — default 30.
  • MaxAttempts — default 5. Backoff is exponential (1s, 2s, 4s, 8s, 16s) with ±25% jitter.
  • IncludeStdout / IncludeStderr — default true. Allow turning off for endpoints whose output is sensitive or huge. Truncation cap (MaxOutputBytes, default 64KB) applies regardless.
  • TriggerOnComplete (default — fires whether script succeeded or failed) | OnSuccess | OnFailure.

Payload shape (application/json; charset=utf-8):

{
  "runId": "8f4e...",
  "endpoint": "deploy",
  "startedAt": "2026-05-07T18:22:11.103Z",
  "completedAt": "2026-05-07T18:22:13.811Z",
  "durationMs": 2708,
  "exitCode": 0,
  "succeeded": true,
  "stdout": "...",
  "stderr": "",
  "stdoutTruncated": false,
  "stderrTruncated": false
}

When AuthMode = Hmac, the HMAC is computed over the raw serialized JSON body bytes with the configured algorithm and added as the configured header (e.g. X-Hub-Signature-256: sha256=...).

CallbackDispatcher:

  • Single BackgroundService with a bounded Channel<CallbackPayload> queue (default capacity 1024; overflow drops oldest with a warning log).
  • Uses a singleton HttpClient (no per-request allocation, follows redirects only on 3xx-with-Location for safety).
  • Per-attempt timeout = TimeoutSeconds. Total deadline = MaxAttempts * (TimeoutSeconds + max-backoff).
  • Retries on network failure, 408, 425, 429, 5xx. Honors Retry-After if present, capped at 60s.
  • All attempts logged: outbound URL, status code, attempt number, latency, final disposition (delivered / dropped / gave-up).
  • GUI surfaces a per-endpoint counter: pending / delivered / failed in last hour.

No caller-supplied callback URLs. The endpoint config is the only source. Accepting a ?callback= parameter from the request would turn the server into an SSRF gadget — easy to point at internal admin endpoints or cloud-metadata services. Callers who need dynamic fan-out can configure multiple endpoints, or run a small dispatcher script themselves.

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:

  • IpAllowList parses 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 with IPAddress.MapToIPv4() before the check.
  • A server-level TrustedProxies list (also IPs/CIDRs) controls whether X-Forwarded-For / X-Real-IP is honored. If the direct connection comes from a trusted proxy, walk X-Forwarded-For from 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 TrustedProxies is 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-time CryptographicOperations.FixedTimeEquals.
  • HMAC: configurable per endpoint:
    • Algorithm: SHA1 / SHA256 (default) / SHA512
    • HeaderName: default X-Hub-Signature-256
    • Prefix: default sha256= (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:

  1. Path to .pfx + password (password DPAPI-encrypted in config).
  2. Local cert-store thumbprint (CurrentUser\My or LocalMachine\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 shape
  • src/WebhookServer.Core/Auth/HmacVerifier.cs — must use raw body + fixed-time compare
  • src/WebhookServer.Core/Auth/IpAllowList.cs — IPv4/IPv6 + CIDR matching, IPv4-mapped IPv6 normalization, runs before auth
  • src/WebhookServer.Core/Execution/ProcessExecutor.cs — argv assembly per ExecutorType, stdin/env wiring, timeout
  • src/WebhookServer.Core/Storage/DpapiSecret.csLocalMachine scope, base64 wire format
  • src/WebhookServer.Core/Ipc/PipeSecurityFactory.cs — correct ACL or the GUI will fail silently for non-admins
  • src/WebhookServer.Service/WebhookHost.cs — Kestrel setup with EnableBuffering() for body capture
  • src/WebhookServer.Service/WebhookRouter.cs — auth → context → dispatch pipeline
  • src/WebhookServer.Core/Callbacks/CallbackDispatcher.cs — bounded queue + retry/backoff; reuses HMAC code from Auth/HmacVerifier.cs for outbound signing
  • src/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:

  1. Install service: sc.exe create WebhookServer binPath= "C:\path\to\WebhookServer.Service.exe" start= auto sc.exe start WebhookServer

  2. Launch GUI (as Administrator). Confirm it connects to the pipe; the status indicator should show "running".

  3. Smoke test (no auth, sync, PowerShell inline):

    • Endpoint slug /hook/ping, executor PowerShell, command 'pong'.
    • curl http://localhost:8080/hook/ping → body contains pong, exit code 0.
  4. Bearer auth:

    • Set bearer secret s3cret.
    • curl http://localhost:8080/hook/ping → 401.
    • curl -H "Authorization: Bearer s3cret" http://localhost:8080/hook/ping → 200.
  5. HMAC auth (GitHub-style):

    • Algo SHA256, header X-Hub-Signature-256, prefix sha256=, secret topsecret.
    • 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.
  6. 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 contains got repo=acme name=bob.
  7. Env-var passing:

    • cmd one-liner: echo event=%WEBHOOK_HEADER_X_GITHUB_EVENT%
    • curl -H "X-GitHub-Event: push" ... → output contains event=push.
  8. Async endpoint:

    • PowerShell: Start-Sleep 10; "done". Mode = Async.
    • curl ... returns 202 immediately. GUI log shows done ~10s later.
  9. Concurrency:

    • Endpoint with serialize=true and a Start-Sleep 5 script. 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 from 10.10.1.42 → 200; from 10.10.2.5 → 403.
    • Empty list → all IPs allowed (regression check).
    • With TrustedProxies empty, send X-Forwarded-For: 127.0.0.1 from a non-allowed IP → still 403 (header not trusted).
    • With TrustedProxies = ["10.10.1.5"] and request coming from that IP carrying X-Forwarded-For: 192.168.50.20, allowlist ["192.168.50.20"] → 200. 9b. Outbound callback (async + HMAC-signed):
    • Stand up a tiny receiver: python -m http.server 9000 won't verify HMAC, so use a one-off script that prints the body and signature. Or another endpoint on this same server.
    • Configure an async endpoint with callback: Url = http://localhost:9000/sink, AuthMode = Hmac, secret cbsecret.
    • Trigger the webhook. After the script finishes, the receiver should see a POST with body { runId, exitCode, stdout, ... } and X-Hub-Signature-256: sha256=<hmac>.
    • Verify HMAC matches by recomputing with the secret — this proves outbound HMAC reuses the inbound code path correctly.
    • Stop the receiver, fire another async run → callback should retry with exponential backoff and eventually log gave-up after MaxAttempts.
    • Bring receiver back up before MaxAttempts exhausts → next retry delivers successfully.
  10. HTTPS:

    • In GUI, bind a self-signed .pfx (New-SelfSignedCertificate ... -CertStoreLocation Cert:\LocalMachine\My).
    • curl -k https://localhost:8443/hook/ping → 200.
  11. Reboot test:

    • Reboot. Verify service auto-starts and an existing endpoint still answers without launching the GUI.
  12. 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.