Files
webhook-server/PLAN.md
T
justin 2a4b1b3adb Initial plan and README for Windows webhook server
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>
2026-05-07 00:27:50 -04:00

272 lines
17 KiB
Markdown

# 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 |
## 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.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`:
- `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 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. 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 (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.
## 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.
```jsonc
// 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.cs``LocalMachine` 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.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.
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.