From 2a4b1b3adb2875f581a22bc9122006c84275e032 Mon Sep 17 00:00:00 2001 From: Justin Paul Date: Thu, 7 May 2026 00:27:50 -0400 Subject: [PATCH] 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) --- .gitignore | 47 ++++++++++ PLAN.md | 271 +++++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 87 +++++++++++++++++ 3 files changed, 405 insertions(+) create mode 100644 .gitignore create mode 100644 PLAN.md create mode 100644 README.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..78ba839 --- /dev/null +++ b/.gitignore @@ -0,0 +1,47 @@ +# Build output +bin/ +obj/ +out/ +publish/ +*.user +*.suo +*.userprefs + +# Visual Studio / Rider / VS Code +.vs/ +.vscode/ +.idea/ +*.swp + +# Test results +TestResults/ +*.trx +*.coverage +*.coveragexml + +# NuGet +*.nupkg +*.snupkg +.nuget/ +packages/ +project.lock.json +project.assets.json + +# DotNet tooling +.dotnet/ + +# Logs / config produced at runtime (not source) +logs/ +*.log +config.local.json + +# OS +.DS_Store +Thumbs.db + +# Secrets — never commit these +*.pfx +*.p12 +*.key +secrets.json +appsettings.*.local.json diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..bca6c63 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,271 @@ +# 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 ` (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 ""` or `-Command ""`. + - `PwshCore` → `pwsh.exe ...` (same flags). + - `Cmd` → `cmd.exe /c ""`. + - `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_=value`; for each header `WEBHOOK_HEADER_=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 `. 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": "" }`. 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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..70a0aff --- /dev/null +++ b/README.md @@ -0,0 +1,87 @@ +# webhook-server + +A Windows-native webhook server that runs PowerShell, PowerShell Core, cmd / `.bat`, or arbitrary executables in response to incoming HTTP requests. Endpoints are configured in a desktop GUI; the actual server runs as a Windows Service so it survives reboots and works without anyone logged in. + +**Status:** planning complete, implementation pending. See [PLAN.md](PLAN.md) for the full design. + +## Highlights + +- **Many endpoints, one service.** Each webhook is a configured URL slug mapped to a script or command. +- **Per-endpoint auth.** Pick HMAC signature (GitHub/Stripe-style), bearer token, or none. +- **Per-endpoint IP allowlist.** Restrict by IP or CIDR (IPv4 + IPv6). Empty list = open. Checked before auth. +- **Flexible execution.** Windows PowerShell 5.1, PowerShell 7+, cmd / `.bat`, or any `.exe`. +- **Flexible input.** Any combination of: JSON body to stdin, query/headers as env vars, `{{template}}` arg expansion. +- **Sync or async per endpoint.** Sync returns exit code + stdout/stderr; async returns 202 immediately. +- **Service-first.** Always-on Windows Service. The WPF GUI is a thin config/monitor client over a named pipe. +- **HTTPS optional.** Bind a `.pfx` or cert-store thumbprint from the GUI; HTTP works out of the box. +- **Secrets at rest.** Tokens and HMAC secrets are encrypted via DPAPI (LocalMachine scope) in `config.json`. + +## Architecture + +``` ++------------------+ 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 +``` + +## Project layout (planned) + +``` +WebhookServer.sln +src/ + WebhookServer.Core/ class lib: models, auth, execution, storage, IPC + WebhookServer.Service/ .NET 8 Worker Service (hosts Kestrel + admin pipe) + WebhookServer.Gui/ WPF (.NET 8) MVVM config/monitor client +scripts/ + install-service.ps1 + uninstall-service.ps1 +``` + +## Requirements + +- Windows 10 / 11 or Windows Server 2019+ +- .NET 8 SDK to build, .NET 8 Runtime (or self-contained publish) to run +- Administrator rights to install the service and to run the GUI (the admin named pipe is ACL'd to SYSTEM + Administrators) + +## Building (on Windows) + +```powershell +dotnet restore +dotnet build -c Release +dotnet publish src/WebhookServer.Service -c Release -r win-x64 --self-contained +dotnet publish src/WebhookServer.Gui -c Release -r win-x64 --self-contained +``` + +## Installing the service (on Windows) + +```powershell +# from an elevated PowerShell prompt +sc.exe create WebhookServer binPath= "C:\Program Files\WebhookServer\WebhookServer.Service.exe" start= auto +sc.exe start WebhookServer +``` + +`scripts/install-service.ps1` will wrap this once implemented. + +## Configuration + +The service reads `C:\ProgramData\WebhookServer\config.json`. Edit it through the GUI rather than by hand — the GUI handles DPAPI encryption of secrets and validation of IP allowlist entries. + +## Out of scope for v1 + +- Importing/exporting config across machines (DPAPI LocalMachine scope ties decryption to the host). +- Outbound webhook delivery / retry queues. +- Per-endpoint rate limiting. +- Multi-user RBAC for the GUI. +- Auto-update. + +## License + +Not yet chosen.