From c49a2a12cb1ffe04820efc0bfb8f5bb53c5a2c5f Mon Sep 17 00:00:00 2001 From: Justin Paul Date: Fri, 8 May 2026 10:34:47 -0400 Subject: [PATCH 1/5] Documentation: install/upgrade/uninstall guides + recipes incl. Zerto Adds a docs/ folder under the repo root with full operator documentation aimed at sysadmins (not webhook developers). The Zerto pre/post script recipe is the canonical "why does this exist" walkthrough; the GitHub HMAC, AD password reset, and UI-on-desktop recipes round out common patterns. Pages: - README.md (index) - concepts.md (5-minute "what is a webhook" explainer) - installation.md (interactive + silent install) - upgrading.md (single-click upgrade flow + edge cases) - uninstalling.md (clean removal + wiping ProgramData) - runas-modes.md (Service / InteractiveUser / SpecificUser decision flow) - service-account-and-ad.md (gMSA setup, delegated rights) - network-and-security.md (bind addresses, allowlists, HTTPS, secret storage) - troubleshooting.md (symptom -> first check, common errors) - recipes/zerto-pre-post-scripts.md (canonical use case) - recipes/github-style-hmac.md (GitHub / Stripe-shaped webhooks) - recipes/ad-password-reset.md (gMSA-backed self-service reset) - recipes/ui-on-desktop.md (InteractiveUser pattern) Top-level README.md restructured to point at docs/ as the source of truth, dropping the duplicated installation snippets. Installer ships docs/ alongside the binaries so they're available offline at C:\Program Files\WebhookServer\docs\. GUI Help menu gains a "Documentation" item that opens the docs site in a browser. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 149 +++++------- docs/README.md | 31 +++ docs/concepts.md | 77 ++++++ docs/installation.md | 108 +++++++++ docs/network-and-security.md | 131 +++++++++++ docs/recipes/ad-password-reset.md | 105 +++++++++ docs/recipes/github-style-hmac.md | 122 ++++++++++ docs/recipes/ui-on-desktop.md | 68 ++++++ docs/recipes/zerto-pre-post-scripts.md | 220 ++++++++++++++++++ docs/runas-modes.md | 78 +++++++ docs/service-account-and-ad.md | 149 ++++++++++++ docs/troubleshooting.md | 136 +++++++++++ docs/uninstalling.md | 90 +++++++ docs/upgrading.md | 76 ++++++ installer/webhook-server.iss | 1 + src/WebhookServer.Gui/MainWindow.xaml | 2 + .../ViewModels/MainViewModel.cs | 17 ++ 17 files changed, 1475 insertions(+), 85 deletions(-) create mode 100644 docs/README.md create mode 100644 docs/concepts.md create mode 100644 docs/installation.md create mode 100644 docs/network-and-security.md create mode 100644 docs/recipes/ad-password-reset.md create mode 100644 docs/recipes/github-style-hmac.md create mode 100644 docs/recipes/ui-on-desktop.md create mode 100644 docs/recipes/zerto-pre-post-scripts.md create mode 100644 docs/runas-modes.md create mode 100644 docs/service-account-and-ad.md create mode 100644 docs/troubleshooting.md create mode 100644 docs/uninstalling.md create mode 100644 docs/upgrading.md diff --git a/README.md b/README.md index 3788222..9f94fdb 100644 --- a/README.md +++ b/README.md @@ -1,111 +1,90 @@ -# webhook-server +# 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. +A Windows-native webhook server that runs PowerShell, cmd / `.bat`, or any executable 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. +Designed for sysadmins who want to wire up tools like **Zerto pre/post scripts**, GitHub webhooks, monitoring alerts, or backup jobs to Windows-side automation — without writing a custom listener every time. + +## Quickstart + +1. **Download** the latest installer: +2. **Run it.** UAC accept → next, next, finish. Adds a Start Menu entry, registers and starts the Windows Service. +3. **Open Webhook Server** from the Start Menu (auto-elevates). +4. **File → New endpoint**, configure a slug + script, save, hit the URL. + +Full first-time walkthrough: [docs/installation.md](docs/installation.md) ## 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. +- **Per-endpoint auth** — HMAC signature (GitHub / Stripe / Slack style), bearer token, or none. +- **Per-endpoint IP allowlist.** Restrict by IP or CIDR. Empty list = open. Checked before auth so blocked IPs get a fast 403. +- **Per-endpoint Run As** — run the hook as the service account (default), the user logged in at the keyboard (for UI hooks), or a named domain/local user via password. - **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. -- **Outbound callbacks.** Optional per-endpoint callback URL — service POSTs the run result back after the script finishes. Required for async callers who want to know what happened. HMAC-signed, retried with backoff. Pre-configured only (no SSRF surface from caller-supplied URLs). -- **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`. +- **Flexible input** — any combination of: JSON body to stdin, query / headers as env vars, `{{body.foo.bar}}` template expansion into argv. +- **Sync or async per endpoint.** Sync returns exit code + stdout / stderr to the caller; async returns 202 immediately. +- **Outbound callbacks.** Optional per-endpoint URL the service POSTs run results to after the script finishes. HMAC-signed, retry-with-backoff. Required for async callers who want to know what happened. +- **Configurable network** — bind to specific NICs, set the URL host shown in the GUI, configure trusted reverse proxies. +- **HTTPS optional.** Bind a `.pfx` or cert-store thumbprint from the GUI. +- **Secrets at rest** — bearer tokens, HMAC keys, RunAs passwords, and PFX passwords are DPAPI-encrypted (LocalMachine scope) in `config.json`. +- **Auto-snapshots.** Every config save writes a Config Checkpoint; restore to any point with one click. Last 30 retained. ## 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 ++------------------+ named pipe +-------------------------------+ +| GUI (WPF) | <-------------> | Windows Service | +| add / edit / | SYSTEM+admin | - Kestrel: hook listener | +| view logs | ACL'd | - Admin pipe server | ++------------------+ | - Executor (process runner) | + | - Callback dispatcher | + | - Serilog file logging | + +-------------------------------+ + | + C:\ProgramData\WebhookServer\ + - config.json (DPAPI-encrypted) + - backups\ (auto-snapshots) + - logs\ (daily rolling) ``` -## Project layout (planned) +## Documentation -``` -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 -``` +Everything you need to operate the server: -## Requirements +- [Concepts](docs/concepts.md) — what a webhook is and how this server uses one +- [Installation](docs/installation.md) — interactive and silent install +- [Upgrading](docs/upgrading.md) — single click; what's preserved +- [Uninstalling](docs/uninstalling.md) — clean removal +- [Run As modes](docs/runas-modes.md) — Service / InteractiveUser / SpecificUser +- [Service account & Active Directory](docs/service-account-and-ad.md) — gMSA + delegated rights +- [Network & security](docs/network-and-security.md) — bind addresses, allowlists, HTTPS, secrets +- [Troubleshooting](docs/troubleshooting.md) — common errors and where to look -- 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) +Recipes: -## Building (on Windows) +- [Zerto pre/post scripts → AD / DNS update](docs/recipes/zerto-pre-post-scripts.md) ← **canonical use case** +- [GitHub-style HMAC-signed webhook](docs/recipes/github-style-hmac.md) +- [AD password reset endpoint](docs/recipes/ad-password-reset.md) +- [Pop UI on the user's desktop](docs/recipes/ui-on-desktop.md) -```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 and will accept a `-ServiceAccount` parameter. - -## Service account & Active Directory - -The service runs as `LocalSystem` by default — fine for local-only scripts and read-only AD queries (it authenticates to the domain as the computer account). If your webhook scripts need to **modify** AD (password resets, group changes, etc.), run the service under an account with the right delegated rights: - -- **Recommended: gMSA** — Active Directory generates and rotates the password automatically. - ```powershell - # on a DC, once - New-ADServiceAccount -Name svc-webhookserver -DNSHostName host.domain.local ` - -PrincipalsAllowedToRetrieveManagedPassword "DOMAIN\WebhookHosts" - # on the webhook host - Install-ADServiceAccount svc-webhookserver - sc.exe create WebhookServer binPath= "..." obj= "DOMAIN\svc-webhookserver$" start= auto - ``` - Note the trailing `$` and the absence of `password=`. - -- **Plain domain user** — works on older domains, but you own password rotation: - ```powershell - sc.exe create WebhookServer binPath= "..." obj= "DOMAIN\svc-webhookserver" password= "..." start= auto - ``` - -Don't use `LocalService` — it has no network identity and cannot talk to a domain controller. +## Requirements -> Heads up: any account the service runs under is the account your hook scripts run under. `LocalSystem` is the most powerful local account on the machine — treat webhook script contents as privileged. +- Windows 10 / 11 / Server 2019+ +- x64 +- .NET 8 SDK to build (the released installer includes everything else) -## Configuration +## Building from source -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. +```powershell +git clone https://github.com/recklessop/webhook-server.git +cd webhook-server -## Out of scope for v1 +# Dev install (publishes + copies to C:\Program Files\WebhookServer + registers service) +powershell -ExecutionPolicy Bypass -File scripts\deploy.ps1 -- Importing/exporting config across machines (DPAPI LocalMachine scope ties decryption to the host). -- Per-endpoint rate limiting. -- Multi-user RBAC for the GUI. -- Auto-update. +# Or build the installer locally (requires Inno Setup 6: winget install JRSoftware.InnoSetup) +powershell -ExecutionPolicy Bypass -File scripts\build-installer.ps1 +``` ## License -Not yet chosen. +TBD. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..ea2e3cf --- /dev/null +++ b/docs/README.md @@ -0,0 +1,31 @@ +# Webhook Server documentation + +Webhook Server is a Windows service that runs a script (PowerShell, cmd, or any executable) when an HTTP request hits a URL you choose. It's designed for sysadmins who want to wire a tool like **Zerto pre/post scripts**, GitHub Actions, a monitoring system, or a backup tool into a Windows-side automation step — without writing a custom listener every time. + +## New here? Start with these + +1. [Concepts](concepts.md) — five-minute read on what a webhook is and how this server uses one +2. [Installation](installation.md) — download, install, first endpoint +3. [Recipe: Zerto pre/post scripts → AD / DNS update](recipes/zerto-pre-post-scripts.md) — the canonical reason this exists + +## Topical + +- [Upgrading](upgrading.md) +- [Uninstalling](uninstalling.md) +- [Run As modes — when to use which](runas-modes.md) +- [Service account & Active Directory](service-account-and-ad.md) +- [Network & security](network-and-security.md) +- [Troubleshooting](troubleshooting.md) + +## Recipes (cookbook style) + +- [Zerto pre/post scripts → AD / DNS update](recipes/zerto-pre-post-scripts.md) +- [GitHub-style HMAC-signed webhook](recipes/github-style-hmac.md) +- [AD password reset endpoint](recipes/ad-password-reset.md) +- [Pop UI on the user's desktop](recipes/ui-on-desktop.md) + +## Reference + +- [GitHub repo](https://github.com/recklessop/webhook-server) +- [Latest release](https://github.com/recklessop/webhook-server/releases/latest) +- [Issue tracker](https://github.com/recklessop/webhook-server/issues) diff --git a/docs/concepts.md b/docs/concepts.md new file mode 100644 index 0000000..63d7990 --- /dev/null +++ b/docs/concepts.md @@ -0,0 +1,77 @@ +# Concepts + +If you've never used a webhook before, this is where to start. Five minutes, no surprises. + +## What is a webhook? + +A webhook is just **an HTTP URL that runs something when it gets called.** Some other tool — Zerto, GitHub, your monitoring system, a backup job — does an `HTTP POST` to that URL when an event happens. Whatever's listening on the URL takes that request and does work in response. + +Concretely, a Zerto pre-script might do: + +```powershell +Invoke-WebRequest -Method POST -Uri http://webhooks.contoso.local:8080/hook/start-failover ` + -Body (@{ vmName = $env:ZertoVPGName } | ConvertTo-Json) ` + -ContentType application/json +``` + +…and the server at `webhooks.contoso.local:8080` would receive that POST and run a PowerShell script you wrote. + +## What does this server give you that you don't already have? + +You could write a tiny ASP.NET listener, or run a PowerShell script behind IIS, or hand-craft `HttpListener` plumbing. People do, all the time. The trade-off is that **you then own the listener** — auth, retries, logging, restarts, a service wrapper, secret storage, an admin UI. That's where Webhook Server saves you a weekend. + +What you get out of the box: + +- A real **Windows Service** that survives reboots and runs without anyone logged in +- Per-endpoint **authentication**: Bearer token, HMAC-signed (GitHub / Stripe / Slack style), or none +- Per-endpoint **IP allowlist** (single IPs or CIDR ranges) +- **Run-as identity**: the service runs as `LocalSystem` by default, but each individual hook can run as a domain account, the logged-in user, or whoever — without needing Task Scheduler in the middle +- **Logging** (Serilog, daily-rolling files) plus a GUI tail +- A WPF **GUI** for adding / editing / testing endpoints. No JSON file editing required. +- **Outbound callbacks**: when a hook finishes, the server can POST the result to another URL, signed with HMAC, with retry-and-backoff +- **HTTPS** via `.pfx` or a cert thumbprint from the local cert store +- **Auto-snapshots** of your config on every save, with point-in-time restore from the GUI + +## How the moving parts fit together + +``` ++------------------+ named pipe +-------------------------------+ +| GUI (WPF) | <------------> | Windows Service | +| add / edit / | SYSTEM+admin | - Kestrel: hook listener | +| view logs | ACL'd | - Admin pipe server | ++------------------+ | - Executor (process runner) | + | - Callback dispatcher | + | - Serilog file logging | + +-------------------------------+ + | + C:\ProgramData\WebhookServer\ + - config.json (DPAPI-encrypted secrets) + - backups\ (auto-snapshots) + - logs\ (daily rolling) +``` + +- The **Windows Service** does the actual work: listens for HTTP requests, runs your scripts, writes logs. +- The **GUI** is purely a config + monitoring tool. It talks to the service over a named pipe ACL'd to `SYSTEM` and `Administrators`. You can launch and close the GUI as you like; the service keeps running. +- **Config + secrets** live in `C:\ProgramData\WebhookServer\config.json`. Secrets (bearer tokens, HMAC keys, run-as passwords, PFX passwords) are DPAPI-encrypted with the `LocalMachine` scope, so the same machine can decrypt them under any account but they don't travel to other machines. + +## What's an "endpoint"? + +An endpoint is one URL slug (the part after `/hook/`) plus a configuration: who's allowed to call it, how it's authenticated, what to run when it fires, and what to do with the result. Add as many as you want. + +| Field | What it controls | +|---|---| +| **Slug** | The URL path. `deploy` → `http://host:8080/hook/deploy` | +| **Auth** | None / Bearer / HMAC. None means anyone who can reach the URL can fire it. | +| **Allowed clients** | List of IPs or CIDRs allowed to hit this slug. Empty = anyone reachable. | +| **Executor** | What to run: Windows PowerShell 5.1, PowerShell Core (7+), `cmd` / `.bat`, or a path to any `.exe` | +| **Run As** | Who the script runs as. See [Run As modes](runas-modes.md). | +| **Data passing** | How request data reaches the script — JSON to stdin, headers / query as env vars, `{{template}}` arg expansion | +| **Response mode** | Sync (the HTTP caller waits for the script to finish and gets its output) or Async (returns 202 immediately, runs in background) | +| **Callback** | Optional outbound URL the server POSTs to with the run result. Required for async hooks if the original caller wants the result. | + +## What it isn't + +- **Not an HTTP server for serving static files or pages.** Just hook URLs and a `/healthz`. +- **Not a queue.** No durable persistence of inbound requests; if the service crashes mid-execution that run is lost (the inbound caller will see the connection drop or a timeout). +- **Not multi-tenant.** It's one config, one set of endpoints, one machine. Run multiple instances on different ports / different machines if you need separation. +- **Not an internet-facing public-API server out of the box.** Lock down with HTTPS + auth + IP allowlist + a reverse proxy if you're going to expose it publicly. See [network & security](network-and-security.md). diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..3226555 --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,108 @@ +# Installation + +This page covers a fresh install. If you already have Webhook Server installed, see [Upgrading](upgrading.md). To remove it, see [Uninstalling](uninstalling.md). + +## Requirements + +- Windows 10, Windows 11, or Windows Server 2019 / 2022 / 2025 +- Administrator rights to install the service and to run the GUI +- (Optional, only if you publish from source) .NET 8 SDK + +The installer is **x64 only**. There is no x86 build. + +## 1. Download + +Grab the latest installer from the GitHub Releases page: + +> https://github.com/recklessop/webhook-server/releases/latest + +Look for the asset named `WebhookServer-Setup-X.Y.Z.exe`. + +## 2. Run the installer + +Double-click the `.exe`. UAC will prompt — accept. The wizard: + +- Copies the binaries to `C:\Program Files\WebhookServer\` +- Creates a Start Menu folder named **Webhook Server** with a GUI shortcut + Uninstall shortcut +- Optionally creates a desktop shortcut (checkbox; off by default) +- **Registers the Windows Service** named `WebhookServer`, runs it as `LocalSystem`, sets it to start automatically at boot, and configures it to restart on failure +- Starts the service +- Offers to launch the GUI when finished — leave the checkbox ticked + +The first time the GUI opens, you'll see UAC prompt again because the GUI requires elevation (it talks to the service over a named pipe restricted to `SYSTEM` and the `Administrators` group). Accept it. + +If the GUI's status bar shows a green dot and `Connected — HTTP 8080`, you're done. + +> **Default ports**: HTTP on `8080`, HTTPS off. Both can be changed under **Server → Settings**. Port `8080` is rarely in use on a fresh server but conflicts with some other tools — if you see `Connection refused` later, this is the first thing to check. + +## 3. Add your first endpoint + +In the GUI: + +1. **File → New endpoint** +2. Slug: `ping` +3. Auth → Mode: **None** +4. Executor → Type: **Windows PowerShell** +5. Executor → Inline command: `Write-Output 'pong'` +6. Click **Save** + +The endpoint appears in the grid. Right-click it → **Copy URL**, paste into a browser. You should get back something like: + +```json +{ "runId": "...", "exitCode": 0, "durationMs": 134, "stdout": "pong\r\n", ... } +``` + +That's it. Real-world recipes start with [Zerto pre/post scripts → AD / DNS update](recipes/zerto-pre-post-scripts.md). + +## Silent / unattended install + +For deploying to many machines via Group Policy, SCCM, Intune, Ansible, etc. — the installer is built with [Inno Setup](https://jrsoftware.org/isinfo.php) and supports its standard silent-mode flags: + +```powershell +WebhookServer-Setup-0.1.1.exe /VERYSILENT /SUPPRESSMSGBOXES /NORESTART +``` + +Useful flags: + +| Flag | What it does | +|---|---| +| `/SILENT` | Show progress, no questions | +| `/VERYSILENT` | No UI at all | +| `/SUPPRESSMSGBOXES` | Suppress info / error popups (use with `/SILENT` or `/VERYSILENT`) | +| `/NORESTART` | Don't restart automatically — there's nothing here that needs it, but pair with `/SUPPRESSMSGBOXES` for total quiet | +| `/DIR="C:\Tools\WebhookServer"` | Override the install location | +| `/LOG="C:\Temp\install.log"` | Write a verbose installer log | +| `/TASKS="desktopicon"` | Pre-tick the optional desktop-icon task | + +The post-install service install runs the same `install-service.ps1` script regardless of silent flags. + +## Manual install from source (if you don't want to trust the prebuilt installer) + +```powershell +# clone (or your fork) +git clone https://github.com/recklessop/webhook-server.git +cd webhook-server + +# from an elevated PowerShell: +powershell -ExecutionPolicy Bypass -File scripts\deploy.ps1 +``` + +`deploy.ps1` publishes both projects, copies the binaries to `C:\Program Files\WebhookServer\`, registers the service, and starts it. Re-run after a `git pull` to upgrade. + +To run the service under a non-default account (e.g. a gMSA for AD operations), pass `-ServiceAccount`: + +```powershell +.\scripts\deploy.ps1 -ServiceAccount 'CONTOSO\svc-webhookserver$' +``` + +See [Service account & Active Directory](service-account-and-ad.md) for the full picture. + +## Where things live after install + +| Path | What | +|---|---| +| `C:\Program Files\WebhookServer\` | Binaries (`WebhookServer.Service.exe`, `WebhookServer.Gui.exe`, the icon, install/uninstall scripts) | +| `C:\ProgramData\WebhookServer\config.json` | The configuration. Backups in `backups\`, daily-rolling logs in `logs\`. **Don't edit by hand** — secrets are DPAPI-encrypted and the service won't pick up your changes without a reload. Use the GUI. | +| `\\.\pipe\WebhookServerAdmin` | The named pipe the GUI uses to talk to the service. ACL'd to `SYSTEM` + `Administrators` only. | + +The installer never touches `C:\ProgramData\WebhookServer\`. Uninstalling preserves your config and logs by default; see [Uninstalling](uninstalling.md) for how to wipe them too. diff --git a/docs/network-and-security.md b/docs/network-and-security.md new file mode 100644 index 0000000..f7f3600 --- /dev/null +++ b/docs/network-and-security.md @@ -0,0 +1,131 @@ +# Network & security + +This page covers what's exposed by Webhook Server, how to lock it down, and what's safe to change vs. leave alone. + +## What's listening + +By default the service binds Kestrel to **all interfaces on TCP 8080**. There are two endpoints relevant to outsiders: + +- `GET|POST /hook/` — fires a configured endpoint +- `GET /healthz` — returns `{"ok": true}` for monitoring +- `GET /favicon.ico` — returns 204 to keep browser logs clean + +Plus the admin named pipe `\\.\pipe\WebhookServerAdmin`, which is **only available locally** to processes running as SYSTEM or in the Administrators group. + +## Reducing the network exposure + +### Bind only to specific NICs + +By default the server listens on every IP the host has — useful on a single-NIC desktop, dangerous on a multi-NIC server where one NIC faces the internet. + +In the GUI: **Server → Settings → Network**. Untick "Listen on all interfaces" and tick the specific addresses you want. Save. The service restarts automatically and rebinds. + +Common patterns: + +- **Internal-only**: tick the LAN IP(s), leave loopback ticked too if anything on the box itself calls the hook +- **Loopback-only**: tick `127.0.0.1` and `::1`. Useful when a reverse proxy on the same host fronts the public traffic. +- **One specific IP for hooks**: tick a single IP that you've documented as the webhook endpoint. Helps when you have a multi-homed server and want clear network segmentation. + +### Per-endpoint IP allowlist + +Each endpoint has an **IP allowlist** field. Empty means anyone reachable can call it. Non-empty means deny-by-default — only the listed IPs / CIDRs are allowed: + +``` +192.168.1.0/24 +10.42.0.5 +fd00::/8 +``` + +Mixing IPv4 and IPv6 entries is fine. The check runs **before authentication**, so a blocked IP gets a fast 403 without burning CPU on HMAC validation. + +### Trusted proxies (X-Forwarded-For) + +If the server sits behind a reverse proxy (nginx / IIS / Caddy / Cloudflare Tunnel), the inbound `RemoteIpAddress` will always be the proxy. To make the IP allowlist evaluate the original client instead, configure **Server → Settings → Trusted proxies** with the IP(s) of the proxy: + +``` +10.0.0.5 +``` + +When the inbound connection comes from that IP and includes an `X-Forwarded-For` header, the leftmost entry of the header is treated as the effective client IP for the allowlist check. + +If `Trusted proxies` is empty (default), `X-Forwarded-For` is **ignored entirely**. This is the safe default — it prevents anyone from spoofing their IP by adding the header themselves. + +## Authentication options + +| Mode | When to use | What the caller sends | +|---|---|---| +| **None** | Internal-only on a trusted LAN, or a hook that's safe to fire repeatedly with no side effects | Nothing | +| **Bearer** | Simple authentication. Pick a long random secret and treat it as a password. | `Authorization: Bearer ` | +| **HMAC** | Anything where the body matters and you want tamper-evidence: GitHub webhooks, Stripe events, signed callbacks | A header (default `X-Hub-Signature-256`) containing `sha256=` of the request body keyed by your shared secret | + +For **None**, lean hard on the IP allowlist — that's your only defense. + +For **Bearer**, generate the secret with `[Convert]::ToBase64String((1..32 | %{ Get-Random -Maximum 256 }))` or any password manager. 32+ bytes of entropy. The token sits in `Authorization` headers; HTTPS is **strongly recommended** so it doesn't traverse the network in clear text. + +For **HMAC**, the secret never traverses the network — only the digest does. This is what GitHub / Stripe / Slack use, and it's the right pick for inbound webhooks from internet-facing services. Configure the four fields to match the sender: + +- **Algorithm**: usually SHA256 +- **Header name**: e.g. `X-Hub-Signature-256` (GitHub), `X-Slack-Signature` (Slack), `Stripe-Signature` (Stripe — needs different format) +- **Prefix**: `sha256=` for GitHub-style, none for raw hex +- **Encoding**: hex (most senders) or base64 (some Slack-derived implementations) + +## HTTPS + +HTTP-only is fine for fully-internal use. For anything reachable beyond a trusted LAN, enable HTTPS. + +In **Server → Settings → HTTPS**: + +- **PFX file**: path to a `.pfx` and its password. Easiest if you got a cert from your internal CA or generated a self-signed one with `New-SelfSignedCertificate`. +- **Cert store thumbprint**: the SHA-1 thumbprint of a certificate already imported into `LocalMachine\My`. Best for production where IT manages the cert lifecycle (auto-renewal, revocation). + +The **HTTPS port** defaults to 8443. Both HTTP and HTTPS can be active simultaneously — change `HTTP port` and `HTTPS port` independently. + +After saving HTTPS settings the service restarts and rebinds. There is briefly a "Disconnected" state in the GUI while that happens (1–3 seconds). + +### Using Let's Encrypt + +The server doesn't speak ACME directly. Two practical options: + +1. **Reverse proxy approach** — run nginx / Caddy / IIS in front of Webhook Server. The proxy handles Let's Encrypt; Webhook Server stays HTTP-only on loopback. Configure `Trusted proxies` so allowlists still work on the original client IP. +2. **External cert renewal** — use [`win-acme`](https://www.win-acme.com/) to obtain certs and place them in `LocalMachine\My`. Configure HTTPS by **thumbprint** in the GUI. When `win-acme` rotates the cert it produces a new thumbprint, so you'll need to update the GUI; or have a small scheduled task that calls the admin pipe to update the binding (advanced, undocumented for now). + +## Secrets at rest + +All secrets — bearer tokens, HMAC keys, PFX passwords, RunAs passwords — are encrypted in `config.json` using **DPAPI with the `LocalMachine` scope**: + +- The same machine can decrypt them under any account (so changing the service account doesn't break secret access). +- Copying `config.json` to a different machine **doesn't carry the secrets** — DPAPI LocalMachine binds to the host's machine key. This is by design and protects against config exfiltration. +- The GUI displays decrypted secrets in plaintext for an admin user. This is intentional. Anyone who can connect to the admin pipe is already SYSTEM-equivalent on the host; pretending otherwise just makes secret recovery harder. + +For backup-and-restore across machines, you'd need to either: + +- Re-enter all secrets on the new host (use the **Export config → manual secret re-entry** flow) +- Bind a custom DPAPI scope (not currently supported — would require a v0.x feature request) + +## The admin pipe + +`\\.\pipe\WebhookServerAdmin` carries the GUI's commands to the service. Its security descriptor allows full control to: + +- `NT AUTHORITY\SYSTEM` +- `BUILTIN\Administrators` + +Everyone else gets denied at the OS level — there's no auth layer in the protocol itself because the ACL is the auth layer. UAC token splitting means a non-elevated process owned by an Admin user is **also denied** (because the user's standard token has Admins as deny-only). That's why the GUI exe is manifested with `requireAdministrator` — it auto-elevates so the pipe accepts the connection. + +If you ever need to grant pipe access to another local group (e.g., a custom `WebhookOperators` group), edit `src/WebhookServer.Core/Ipc/PipeSecurityFactory.cs` and add an `AddAccessRule` for that group. Currently no GUI configures this. + +## Threat model summary + +What you're protected against, by default: + +- **Random scanners hitting your hooks** — solved by IP allowlists (when configured), auth (when configured), and HTTPS (when configured) +- **Replay of inbound requests** — HMAC signs the body, so a captured request can't be modified, but it CAN be replayed. If that matters, include a timestamp in the body and reject old timestamps in your script. +- **Credential leaks** — secrets at rest are DPAPI-encrypted, machine-bound; they don't travel with `config.json` +- **Privilege escalation via the admin pipe** — pipe ACL excludes non-admins +- **Local user spoofing the source IP** — `X-Forwarded-For` is ignored unless you explicitly trust a proxy + +What you're NOT protected against — these are out of scope for this server: + +- Compromise of an admin account on the host (game over — they own everything) +- A malicious script you configured (you wrote it; the server just runs it) +- DoS via volume of requests — there's no rate limiting in v0.x +- Memory dump of the running service revealing decrypted secrets — DPAPI protects at-rest only diff --git a/docs/recipes/ad-password-reset.md b/docs/recipes/ad-password-reset.md new file mode 100644 index 0000000..e80a247 --- /dev/null +++ b/docs/recipes/ad-password-reset.md @@ -0,0 +1,105 @@ +# Recipe: AD password reset endpoint + +A self-service password reset URL your help-desk tool can hit. Single endpoint, gMSA-backed, audited. + +## Architecture + +- The webhook host is domain-joined +- The service runs as a gMSA with **Reset Password** + **Write pwdLastSet** delegated on the OUs containing target users +- The endpoint is HMAC-signed, IP-allowlisted to the help-desk app's server +- Every reset is logged in the daily log file with caller IP, target user, runId, and result + +## Prerequisites + +- gMSA created and installed on the host. See [Service account & Active Directory](../service-account-and-ad.md). +- Service installed with `-ServiceAccount 'CONTOSO\svc-webhookserver$'` +- Delegate the right permissions on the OU(s): + +```powershell +$ou = "OU=Standard Users,DC=contoso,DC=local" +dsacls $ou /I:S /G "CONTOSO\svc-webhookserver$:CA;Reset Password;user" +dsacls $ou /I:S /G "CONTOSO\svc-webhookserver$:WP;pwdLastSet;user" +``` + +## The script + +`C:\Scripts\ad-password-reset.ps1`: + +```powershell +[CmdletBinding()] +param() +$ErrorActionPreference = 'Stop' +Import-Module ActiveDirectory + +$body = $input | ConvertFrom-Json + +if (-not $body.samAccountName) { throw 'samAccountName is required' } +if (-not $body.newPassword) { throw 'newPassword is required' } +if (-not $body.requestedBy) { throw 'requestedBy is required (audit field)' } + +# Refuse to touch privileged groups +$user = Get-ADUser -Identity $body.samAccountName -Properties MemberOf +$denyGroups = @('Domain Admins','Enterprise Admins','Schema Admins') +foreach ($g in $user.MemberOf) { + $name = ($g -split ',')[0] -replace '^CN=' + if ($denyGroups -contains $name) { + throw "refusing to reset password for member of $name" + } +} + +$secure = ConvertTo-SecureString $body.newPassword -AsPlainText -Force +Set-ADAccountPassword -Identity $user -NewPassword $secure -Reset +Set-ADUser -Identity $user -ChangePasswordAtLogon $true + +# Audit line goes to the webhook log automatically (return value becomes stdout). +"reset $($user.SamAccountName) requested by $($body.requestedBy)" +``` + +## Endpoint configuration + +| Section | Setting | Value | +|---|---|---| +| Identity | Slug | `ad-reset` | +| Auth | Mode | **HMAC** with a strong secret shared with the help-desk app | +| Auth | HMAC header | `X-Signature-256` | +| Auth | HMAC prefix | `sha256=` | +| Auth | HMAC encoding | hex | +| Allowed clients | | `10.50.10.20` *(the help-desk app's IP only)* | +| Executor | Type | Windows PowerShell | +| Executor | Script path | `C:\Scripts\ad-password-reset.ps1` | +| Data passing | JSON body to stdin | ✓ | +| Data passing | Headers/query as env vars | ✗ | +| Run as | Identity | **Service** *(uses the gMSA)* | +| Response | Mode | Sync | +| Response | Timeout (sec) | 30 | +| Response | Fail on non-zero exit | ✓ | + +## Calling it + +```powershell +$body = @{ + samAccountName = 'jdoe' + newPassword = 'TempP@ssw0rd!2026' + requestedBy = 'helpdesk_user@contoso.local' +} | ConvertTo-Json + +$bytes = [Text.Encoding]::UTF8.GetBytes($body) +$hmac = [Security.Cryptography.HMACSHA256]::new( + [Text.Encoding]::UTF8.GetBytes('your-shared-secret')) +$sig = ([BitConverter]::ToString($hmac.ComputeHash($bytes)) -replace '-','').ToLower() + +Invoke-RestMethod -Method POST ` + -Uri 'http://webhooks.contoso.local:8080/hook/ad-reset' ` + -Headers @{ 'X-Signature-256' = "sha256=$sig" } ` + -ContentType 'application/json' -Body $body +``` + +## Operational notes + +**Audit log**: every call lands in `C:\ProgramData\WebhookServer\logs\webhook-YYYYMMDD.log` with one line per run including the runId, slug, caller IP, exit code, and the script's stdout (the `"reset jdoe requested by helpdesk_user"` line). Ship those logs to your SIEM via the usual file-collector flow. + +**Rotating the HMAC secret**: edit the endpoint in the GUI, replace the secret, save. The help-desk app needs the new secret too — coordinate the cutover. There's no overlap window built in; if you need a soft rollover, create a second endpoint with the new secret and switch caller traffic over. + +**Privileged-group guard**: the script's `denyGroups` check is a basic guard. If a more sophisticated guard is needed (target user attribute, OU-based logic), add it in the script — that's the right place, not the webhook server. + +**Self-service from the user side**: don't expose this endpoint to end users directly. Front it with a help-desk app that authenticates the user (preferably with MFA), then makes the call to the webhook with its bearer/HMAC credentials. The webhook server is the *plumbing*; not the *front door*. diff --git a/docs/recipes/github-style-hmac.md b/docs/recipes/github-style-hmac.md new file mode 100644 index 0000000..7a6bc62 --- /dev/null +++ b/docs/recipes/github-style-hmac.md @@ -0,0 +1,122 @@ +# Recipe: GitHub-style HMAC-signed webhook + +GitHub, Stripe, Slack, Shopify, and most SaaS providers sign their outbound webhooks with HMAC. The receiver computes the same HMAC over the request body using a shared secret and rejects the request if the signatures don't match. Webhook Server has this built in — you just point a real GitHub webhook at your endpoint. + +## What we're building + +A webhook URL that GitHub calls on every push to a repo. The server runs a PowerShell script that pulls the latest commit and triggers a deployment. Authentication is HMAC-SHA256 over the request body, using the secret you configured in GitHub's webhook settings. + +## On the GitHub side + +In your repo: **Settings → Webhooks → Add webhook**. + +| Field | Value | +|---|---| +| Payload URL | `https://hooks.contoso.com/hook/gh-deploy` (yes, HTTPS — GitHub enforces it for public hosts) | +| Content type | `application/json` | +| Secret | Generate a long random string. Copy it for the next step. | +| SSL verification | Enable | +| Events | Just `push` | + +Save. GitHub immediately delivers a `ping` event for testing. You'll see it in **Recent Deliveries** with whatever response code your server returns. + +## The PowerShell deployment script + +`C:\Scripts\gh-deploy.ps1`: + +```powershell +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' + +$payload = $input | ConvertFrom-Json + +# Verify the event type via the X-GitHub-Event header passed as an env var +$event = $env:WEBHOOK_HEADER_X_GITHUB_EVENT +if ($event -eq 'ping') { + "got ping from $($payload.repository.full_name)" + return +} +if ($event -ne 'push') { + Write-Error "ignoring $event event" +} + +$repo = $payload.repository.full_name +$branch = $payload.ref -replace '^refs/heads/', '' +$sha = $payload.after + +if ($branch -ne 'main') { + "ignoring push to $branch" + return +} + +$repoDir = "C:\Deploys\$($payload.repository.name)" +if (-not (Test-Path $repoDir)) { + git clone "https://github.com/$repo.git" $repoDir +} + +Push-Location $repoDir +try { + git fetch --all + git reset --hard $sha + # ...your build/deploy steps here... + & npm ci + & npm run build + Restart-Service MyAppService +} +finally { + Pop-Location +} + +"deployed $repo @ $sha" +``` + +## Configure the endpoint + +**File → New endpoint**: + +| Section | Setting | Value | +|---|---|---| +| Identity | Slug | `gh-deploy` | +| Auth | Mode | **HMAC** | +| Auth | HMAC secret | paste the GitHub-side secret | +| Auth | HMAC header | `X-Hub-Signature-256` *(GitHub's default)* | +| Allowed clients | | `140.82.112.0/20`, `192.30.252.0/22` *(GitHub's webhook IP ranges; check [docs.github.com](https://api.github.com/meta) for the live list)* | +| Executor | Type | **Windows PowerShell** | +| Executor | Script path | `C:\Scripts\gh-deploy.ps1` | +| Data passing | JSON body to stdin | ✓ | +| Data passing | Headers/query as env vars | ✓ *(needed so `WEBHOOK_HEADER_X_GITHUB_EVENT` is set)* | +| Run as | Identity | **Service** (default) — assumes the deployment is local | +| Response | Mode | **Async** *(GitHub times out fast; don't make it wait for the build)* | +| Response | Timeout (sec) | `600` | + +Save. + +## What HMAC does for you here + +GitHub computes `sha256(body, secret)` and sends it as `sha256=` in `X-Hub-Signature-256`. Webhook Server computes the same hash, verifies in fixed time, and rejects (401) on mismatch. + +This means: + +- A request with a tampered body fails the check +- A captured request can be **replayed verbatim** (the signature is valid for that body) — if that matters, GitHub also includes a `X-GitHub-Delivery` ID and timestamp you can deduplicate against +- The secret never travels over the network — only the digest does, so HTTPS is for confidentiality of the body, not the secret + +## Adapting for Stripe, Slack, etc. + +Same pattern, different headers and signing details. The four HMAC fields in the editor cover all common variants: + +| Provider | Header | Prefix | Encoding | Algorithm | +|---|---|---|---|---| +| GitHub | `X-Hub-Signature-256` | `sha256=` | hex | SHA-256 | +| Stripe | `Stripe-Signature` | (none — but Stripe's format is multipart, see below) | hex | SHA-256 | +| Slack | `X-Slack-Signature` | `v0=` | hex | SHA-256 | +| Generic / custom | configurable | configurable | configurable | SHA-1 / SHA-256 / SHA-512 | + +**Stripe** is special: their `Stripe-Signature` header has the format `t=,v1=,v0=`, where `v1` is HMAC-SHA256 of `.`. Webhook Server's straight HMAC check doesn't match Stripe's signed-with-timestamp scheme. Workarounds: + +- Use **Bearer auth** on Stripe webhooks instead, since you already control the secret +- Or do unauthenticated + IP allowlist + a script-side signature check using their official validation library + +For everything that's "GitHub-shaped" (signed body, raw HMAC), the built-in HMAC mode is the right pick. diff --git a/docs/recipes/ui-on-desktop.md b/docs/recipes/ui-on-desktop.md new file mode 100644 index 0000000..dcd80d3 --- /dev/null +++ b/docs/recipes/ui-on-desktop.md @@ -0,0 +1,68 @@ +# Recipe: Pop UI on the user's desktop + +The classic "fire a hook from your phone, see a calculator window appear on your PC." Useful for: + +- Triggering interactive installers / wizards +- Opening browser tabs to specific dashboards on demand +- Playing a sound / showing a toast notification +- Demos and party tricks + +## Why this is non-trivial on Windows + +The Webhook Server service runs as `LocalSystem` in **session 0**. Anything launched normally from a Service-mode endpoint also lands in session 0, which has no visible desktop — UI runs but nobody sees it. To put a window on the desktop of whoever is logged in at the keyboard, the service has to: + +1. Find the active console session ID (`WTSGetActiveConsoleSessionId`) +2. Get a primary token for the user in that session (`WTSQueryUserToken`) +3. Spawn the new process with `CreateProcessAsUser` against that token, targeting `winsta0\default` + +Webhook Server does all of this for you when the endpoint's **Run as** is set to **InteractiveUser**. + +## Configure the endpoint + +| Section | Setting | Value | +|---|---|---| +| Identity | Slug | `calc` | +| Identity | Description | "Pop calculator on the logged-in user's desktop" | +| Auth | Mode | None / Bearer — your call | +| Allowed clients | | restrict; this is interactive UI | +| Executor | Type | **Executable** | +| Executor | Executable path | `C:\Windows\System32\calc.exe` | +| Run as | Identity | **InteractiveUser** | +| Response | Mode | **Async** *(calc never exits on its own; sync would 30-second-timeout-kill it every time)* | +| Response | Fail on non-zero exit | unticked | + +Save. Hit `http://localhost:8080/hook/calc` from anywhere — calc.exe pops up on your desktop. + +## Limits + +- **Service must run as LocalSystem.** Only SYSTEM has the `SeTcbPrivilege` required for `WTSQueryUserToken`. If you switched the service to a gMSA (e.g. for AD-write hooks), this mode stops working. Run two instances of Webhook Server on different ports if you need both. +- **Someone must be logged in** at the console. If the desktop is at the lock screen with no user signed in, the hook fails with `No active console session - is anyone logged in at the keyboard?`. +- **RDP sessions complicate things.** `WTSGetActiveConsoleSessionId` always returns the *console* session, not RDP sessions. If only RDP users are connected and no one is at the physical keyboard, this mode fails. (A separate API, `WTSQueryUserToken` against an enumerated session ID, can target RDP — that'd be a v0.x feature request.) +- **Multiple users logged in via fast-user-switching** — the hook lands in whichever session is currently active (the foreground desktop), not all of them. + +## Variations + +### Notification toast instead of a window + +Use a PowerShell script that emits a Windows 10/11 toast via `BurntToast` (third-party module) or the built-in WinRT API: + +```powershell +# requires: Install-Module BurntToast +New-BurntToastNotification -Text 'Webhook fired',$($input | Out-String) +``` + +Configure the endpoint as InteractiveUser + WindowsPowerShell + inline command. The toast appears as the logged-in user — same as if they fired it themselves. + +### Open a URL in the user's default browser + +```powershell +Start-Process ($input | ConvertFrom-Json).url +``` + +Body: `{ "url": "https://contoso.servicenow.com/incident/123" }` + +This opens the URL in whatever the user has set as default. Handy for "page on-call → they reply on their phone with a link → URL opens on their workstation when they sit down." + +### Run a setup wizard / installer that needs UI + +Some installers refuse to run silently or have steps that require human input. Wrap them as InteractiveUser hooks so the operator can trigger them from a help-desk console without having to RDP in. diff --git a/docs/recipes/zerto-pre-post-scripts.md b/docs/recipes/zerto-pre-post-scripts.md new file mode 100644 index 0000000..965d61e --- /dev/null +++ b/docs/recipes/zerto-pre-post-scripts.md @@ -0,0 +1,220 @@ +# Recipe: Zerto pre/post scripts → AD / DNS update + +This is the canonical reason Webhook Server exists. Zerto's failover, move, and clone operations support pre- and post-scripts — but those scripts run on the Zerto Virtual Manager (ZVM), not on the destination domain controller or DNS server. To touch AD or DNS during a failover you need either: + +- A bastion / utility host with the right modules and credentials installed (and you accept the maintenance burden of keeping its scripts in sync) +- **A webhook on a Windows host** — Zerto's pre/post calls a single URL, and the webhook server runs the right PowerShell on the right machine with the right identity. This page is about that. + +## What we're building + +A Zerto pre/post script POSTs to `http://webhooks.contoso.local:8080/hook/dr-failover-prep` with a JSON body identifying the VPG and target VMs. The webhook server, running on a domain-joined utility host as a gMSA with delegated AD rights, runs PowerShell that: + +1. Updates AD computer object descriptions to indicate they're now at the DR site +2. Updates DNS A records to point `app01.contoso.local` and friends at the new (DR) IPs +3. Posts a result line to a Teams channel +4. Returns 200 with the summary so it shows up in Zerto's pre/post script log + +It's about ~30 lines of PowerShell on the server side and 3 lines of script in Zerto. + +## Prerequisites + +On the webhook host: + +- Webhook Server installed (see [Installation](../installation.md)) +- The host is domain-joined +- The service account has the **AD permissions** it needs. We'll configure this two ways below — the simple way (LocalSystem + delegated rights to the machine account) and the production way (gMSA). +- DNS PowerShell module installed if you'll modify DNS: `Install-WindowsFeature RSAT-DNS-Server` (Server) or RSAT installed (Win 10/11). +- AD PowerShell module: `Install-WindowsFeature RSAT-AD-PowerShell` (Server). + +On the Zerto side: + +- ZVM 8.x or 9.x (this works with both) +- A Virtual Protection Group (VPG) you want to wire up + +## 1. Plan the script and the inputs + +What does the script need to know? At minimum: + +- **VPG name** — Zerto exposes this as a parameter to the pre/post script +- **VM names** — likewise +- **Target IPs** — depending on your failover topology, these may be static (DR network has known IPs) or known after Zerto reconfigures the IP + +Decide what travels in the request body and what's hardcoded. A pragmatic split: + +- Hardcoded (in the PowerShell script on the webhook host): zone name, AD OU, Teams webhook URL, mapping table from VM hostname → target IP +- Sent in the body: VPG name, list of VM names, an "operation" field (`failover`, `move`, `failback`, etc.) + +Example body the Zerto script will send: + +```json +{ + "operation": "failover", + "vpg": "App-Production", + "vms": ["app01", "app02", "db01"] +} +``` + +## 2. Write the PowerShell script on the webhook host + +Save this as `C:\Scripts\dr-failover-prep.ps1` on the webhook host: + +```powershell +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' + +# Read the body from stdin (the webhook server pipes the JSON in for us when +# StdinJson is enabled). +$body = $input | ConvertFrom-Json + +# Hardcoded site config - edit for your environment. +$dnsServer = 'dc01.contoso.local' +$forwardZone = 'contoso.local' +$adOu = 'OU=Servers,DC=contoso,DC=local' +$teamsWebhook = 'https://contoso.webhook.office.com/...' # one-way, no secret to leak +$drIpMap = @{ + 'app01' = '10.42.10.11' + 'app02' = '10.42.10.12' + 'db01' = '10.42.10.21' +} + +$summary = @() + +foreach ($vm in $body.vms) { + if (-not $drIpMap.ContainsKey($vm)) { + $summary += "skip $vm - no DR IP mapping" + continue + } + $newIp = $drIpMap[$vm] + + # 1. Update DNS A record (delete + recreate is the simplest reliable path) + $existing = Get-DnsServerResourceRecord -ZoneName $forwardZone -Name $vm ` + -RRType A -ComputerName $dnsServer -ErrorAction SilentlyContinue + if ($existing) { + Remove-DnsServerResourceRecord -ZoneName $forwardZone -Name $vm ` + -RRType A -RecordData $existing.RecordData.IPv4Address ` + -ComputerName $dnsServer -Force + } + Add-DnsServerResourceRecordA -ZoneName $forwardZone -Name $vm ` + -IPv4Address $newIp -ComputerName $dnsServer -TimeToLive 00:05:00 + + # 2. Update AD computer description so on-call can see at a glance + Set-ADComputer -Identity $vm -Description "[DR-$($body.operation)] $(Get-Date -Format s)" + + $summary += "ok $vm -> $newIp" +} + +# 3. Notify Teams +$msg = @{ + text = "Webhook DR prep for VPG **$($body.vpg)** ($($body.operation)):`n" + + ($summary -join "`n") +} | ConvertTo-Json +Invoke-RestMethod -Uri $teamsWebhook -Method POST -ContentType 'application/json' -Body $msg | Out-Null + +# 4. Print the summary so Zerto's pre/post script log captures it +$summary -join "`n" +``` + +A few choices worth calling out: + +- **`$input | ConvertFrom-Json`** — Webhook Server pipes the request body into the script via stdin when "JSON body to stdin" is ticked. `$input` is PowerShell's automatic variable for pipeline input. +- **`$ErrorActionPreference = 'Stop'`** — turn cmdlet warnings into terminating errors so the script exits non-zero on real problems. Webhook Server then returns 502 (configurable via "Fail on non-zero exit") and Zerto sees the failure. +- **Two-way Teams notification but one-way return** — the script's stdout becomes the HTTP response. Zerto logs it. The Teams notification is a separate Invoke-RestMethod. + +## 3. Configure the endpoint in the GUI + +In Webhook Server's GUI, **File → New endpoint**: + +| Section | Setting | Value | +|---|---|---| +| Identity | Slug | `dr-failover-prep` | +| Identity | Description | "Zerto pre-script: update AD/DNS during failover" | +| Auth | Mode | **Bearer** | +| Auth | Bearer secret | generate a 32-byte random string; copy it for the Zerto script | +| Allowed clients | (one per line) | `10.0.0.0/8` (your ZVM's network) | +| Executor | Type | **Windows PowerShell** | +| Executor | Script path | `C:\Scripts\dr-failover-prep.ps1` | +| Data passing | JSON body to stdin | ✓ | +| Data passing | Headers/query as env vars | ✗ | +| Run as | Identity | **Service** if the service is running as a gMSA with AD rights, otherwise **SpecificUser** with a delegated account | +| Response | Mode | **Sync** | +| Response | Timeout (sec) | `60` | +| Response | Fail on non-zero exit | ✓ | + +Save. Right-click the row → **Copy URL** to grab the full URL, e.g. `http://webhooks.contoso.local:8080/hook/dr-failover-prep`. + +> **Why Bearer auth and not None?** Even though the IP allowlist limits who can reach this endpoint, the Bearer token is a defense-in-depth layer. If someone managed to spoof or get on the trusted network, they still need the token. Generate it once, store it in a secrets manager (or in Zerto's encrypted script parameters), and never email it. + +## 4. The Zerto pre/post script + +Zerto pre/post scripts are PowerShell files placed on the ZVM. The path varies by Zerto version; in 9.x it's typically `C:\Program Files\Zerto\Zerto Virtual Replication\Scripts\`. + +Create `dr-failover-prep.ps1` on the ZVM: + +```powershell +# Zerto passes context as parameters/environment - exact names vary by version. +# Document yours; this is illustrative. +param( + [string]$VpgName = $env:ZertoVPGName +) + +$webhookUrl = 'http://webhooks.contoso.local:8080/hook/dr-failover-prep' +$bearer = 'paste-the-bearer-secret-here' # store via Zerto secret param if available + +# Build the body. In a real script, list the VMs by querying Zerto's API or by +# convention from the VPG name. +$body = @{ + operation = 'failover' + vpg = $VpgName + vms = @('app01','app02','db01') +} | ConvertTo-Json + +$response = Invoke-RestMethod -Method POST -Uri $webhookUrl -Body $body ` + -ContentType 'application/json' -TimeoutSec 90 ` + -Headers @{ Authorization = "Bearer $bearer" } + +# Print whatever the webhook returned to Zerto's log. +$response.stdout +``` + +Wire this script into your VPG's **Pre-Recovery** or **Post-Recovery** hook in the Zerto UI. + +## 5. Test before going live + +In a maintenance window, hit the endpoint manually with a fake VPG name to confirm the wiring works: + +```powershell +$body = @{ operation='test'; vpg='SmokeTest'; vms=@('app01') } | ConvertTo-Json +Invoke-RestMethod -Method POST ` + -Uri http://webhooks.contoso.local:8080/hook/dr-failover-prep ` + -Headers @{ Authorization = "Bearer paste-the-secret" } ` + -ContentType application/json -Body $body +``` + +You should see the summary line(s) come back, AD descriptions update, DNS A records update, and a Teams notification. If anything's off: + +- **No response, hang** → check the GUI's log panel. The auto-poll updates every 3 seconds. Look for the run line with the slug + exit code. +- **401 Unauthorized** → bearer mismatch +- **403 Forbidden** → IP allowlist blocking you +- **502 Bad Gateway** → script ran but exited non-zero. The response body has stderr. + +After a real failover triggers it, audit by checking the daily log file at `C:\ProgramData\WebhookServer\logs\webhook-YYYYMMDD.log` for the `Run dr-failover-prep ok exit=0` line. + +## Variations + +### Different actions for failover vs. failback + +Pass an `operation` field in the body and branch on it in the PowerShell. The script above already does this — extend the `switch` to handle `failback` (revert DNS to production IPs, clear DR description, etc.). + +### Per-VPG endpoints + +If you want fine-grained access control per VPG, create one endpoint per VPG and give each its own bearer secret. The GUI's grid handles dozens of endpoints fine. + +### Async + callback for long-running work + +If your AD/DNS update genuinely takes minutes (e.g., updating thousands of records in a large environment), set the endpoint to **Async** mode. Zerto's pre-script gets `202 Accepted` immediately and continues. Configure the endpoint's **Callback** with a URL that records the result (e.g., another endpoint that logs to a file, or your monitoring system's API). + +### Audit trail to a SIEM + +Configure each endpoint's **Callback** with your SIEM's HTTP collector URL + an HMAC secret. Every run produces a JSON record with runId, exit code, duration, stdout, and stderr — perfect for compliance audit logs. diff --git a/docs/runas-modes.md b/docs/runas-modes.md new file mode 100644 index 0000000..ff5c3b9 --- /dev/null +++ b/docs/runas-modes.md @@ -0,0 +1,78 @@ +# Run As modes — when to use which + +Each endpoint has a **Run As** setting (in the editor's "Run as" section) that controls *who* the script runs as. The default works for most cases, and switching modes is one dropdown change. + +## The three modes + +| Mode | Runs as | Use when… | +|---|---|---| +| **Service** *(default)* | Whoever the Windows Service runs under (LocalSystem by default) | Almost everything. Local file ops, calling local APIs, running cmd / PowerShell scripts that don't need a user identity. | +| **InteractiveUser** | The user logged in at the keyboard | The script needs to put a window on the screen (Calculator, a notification dialog, opening a browser tab) | +| **SpecificUser** | A named local or domain user / password you provide | The script runs in AD, a fileshare, or any system that wants the action attributed to a specific identity — and you don't want the service itself running as that user. | + +## Service (default) + +Nothing to configure. The hook runs as `LocalSystem` by default — full local rights, very limited network identity (the machine account on a domain). + +You can change the service identity at install time via the `-ServiceAccount` parameter to `install-service.ps1` (gMSA, domain user, etc.). Anything you set there applies to **all** Service-mode endpoints. See [Service account & Active Directory](service-account-and-ad.md). + +**Pros**: zero config per endpoint, no passwords to manage, fastest path +**Cons**: the script can't pop UI on the user's desktop (Session 0 isolation), and on a workgroup machine it has no domain identity at all + +## InteractiveUser + +Pick this when the hook should appear visually on the desktop of whoever is logged in. The clearest example is "fire a hook from my phone, get a Calculator window on my PC." + +How it works internally: the service (running as SYSTEM) calls the Win32 API `WTSQueryUserToken` to grab the active console session's user token, then `CreateProcessAsUser` to land the new process inside that session. + +What you don't have to configure: username, password, profile loading, session ID. All inferred at runtime. + +What can go wrong: + +- **No one logged in** at the keyboard → hook fails with `No active console session - is anyone logged in at the keyboard?`. The hook can't run; there's no desktop to land on. +- **Service runs as anything other than LocalSystem** → `WTSQueryUserToken` requires SYSTEM. If you switched the service to a gMSA / domain user, InteractiveUser stops working. +- **Locked desktop, no user logged in but session 1 reserved** → similar to "no one logged in." Once a user logs in interactively (even just to the lock screen with credentials cached), the session is "active enough" for this to work. + +**Use case examples**: see [recipes/ui-on-desktop.md](recipes/ui-on-desktop.md). + +## SpecificUser + +Pick this when the hook needs to authenticate as a specific account — a service account with delegated AD rights, a local Administrator on a remote machine, etc. — but you don't want the *whole service* running as that account. + +Configure: + +- **Username**: `DOMAIN\user`, `.\local-user`, or a UPN like `user@contoso.com`. The leading `.\` is shorthand for the local machine. +- **Password**: stored DPAPI-encrypted at rest. Visible in plaintext in the GUI for an admin user, by design — anyone with admin pipe access already has SYSTEM-equivalent rights. +- **Load profile**: optional. Loads the user's HKCU and AppData before running. Slower (~1s extra). Only needed if the script reads user-scoped settings (uncommon). + +How it works internally: the service calls `LogonUser` with the credentials (interactive logon type first, falls back to batch logon for service-only accounts), then `DuplicateTokenEx` + `CreateProcessAsUser`. The script lands in a fresh batch session with the user's network identity. + +> **Why not `psi.UserName` / `psi.Password` like a normal .NET app?** Because `CreateProcessWithLogonW` (what those properties use under the hood) refuses to run when the caller is `LocalSystem`, which is exactly our scenario. The token-based path is the documented Windows mechanism for this. + +What can go wrong: + +- **Wrong password** → log shows `LogonUser (DOMAIN\user) failed - The user name or password is incorrect`. Re-enter in the editor. +- **Account is denied logon locally** → log shows `Logon failure: the user has not been granted the requested logon type`. Make sure the account has at least one of *Log on as a batch job* or *Log on locally* under `secpol.msc` → Local Policies → User Rights Assignment. +- **Domain controller unreachable** → for domain accounts, the service must be able to reach a DC. For local accounts (`.\name`), no domain dependency. + +## Decision flowchart + +``` + Need UI on the user's desktop? + │ + ┌─────── yes ─────┴────── no ─────┐ + │ │ + InteractiveUser Need specific identity (AD / fileshare / etc.)? + │ + ┌──── yes ────┴──── no ────┐ + │ │ + Should ALL hooks run as Service + this identity? + │ + ┌────── yes ──────────┴───────── no ──────────┐ + │ │ + Run service itself SpecificUser per endpoint + as that account + (gMSA / domain user) + see service-account-and-ad.md +``` diff --git a/docs/service-account-and-ad.md b/docs/service-account-and-ad.md new file mode 100644 index 0000000..42d332d --- /dev/null +++ b/docs/service-account-and-ad.md @@ -0,0 +1,149 @@ +# Service account & Active Directory + +The service runs as `LocalSystem` out of the box. That's right for local-only scripts and **read-only** AD queries (LocalSystem authenticates to the network as the machine account, which Authenticated Users includes by default). It is wrong for hooks that need to **modify** AD — passwords, group memberships, computer objects. + +This page covers the four real-world choices and how to switch. + +## The four options + +| Account | Network identity | When to use | +|---|---|---| +| **`LocalSystem`** *(default)* | Computer account `DOMAIN\MACHINE$` on a domain-joined host; nothing on a workgroup host | Default. Local file ops, simple PowerShell, read-only AD queries. Most powerful local account — any hook running under it has full local rights. | +| **`LocalService`** | None | **Don't.** Cannot talk to a domain controller. Listed only to rule it out. | +| **`NetworkService`** | Same machine account as LocalSystem | Slightly less local privilege than LocalSystem, same network identity. Rarely the right pick. | +| **Domain user** (`DOMAIN\svc-webhookserver`) | That user | Use when hooks need write access to AD and you can't use a gMSA. You own password rotation. | +| **gMSA** (`DOMAIN\svc-webhookserver$`) | That gMSA | **Recommended for AD-write workloads.** AD generates and rotates the password automatically every 30 days. Requires domain functional level 2012+. | + +## Switching the service account at install time + +Pass `-ServiceAccount` to `install-service.ps1` (or to the deploy / dev launcher): + +```powershell +# Domain user +& "C:\Program Files\WebhookServer\scripts\install-service.ps1" ` + -BinaryPath "C:\Program Files\WebhookServer\WebhookServer.Service.exe" ` + -ServiceAccount "CONTOSO\svc-webhookserver" -Password "..." + +# gMSA - note trailing $ and no -Password +& "C:\Program Files\WebhookServer\scripts\install-service.ps1" ` + -BinaryPath "C:\Program Files\WebhookServer\WebhookServer.Service.exe" ` + -ServiceAccount 'CONTOSO\svc-webhookserver$' +``` + +Or do it manually with `sc.exe` if the service is already installed: + +```powershell +sc.exe stop WebhookServer +sc.exe config WebhookServer obj= 'CONTOSO\svc-webhookserver$' +sc.exe start WebhookServer +``` + +## gMSA setup (recommended for AD writes) + +A gMSA is a Group Managed Service Account. Active Directory generates and stores its password and rotates it every 30 days; the host machine account retrieves the password as needed. You never see or store it. This is the cleanest pattern for production. + +### One-time domain setup + +If your domain has never used gMSAs, create the KDS root key (only needed once per domain): + +```powershell +# from a Domain Admin PowerShell, on any DC +Add-KdsRootKey -EffectiveImmediately +# in production wait 10 hours for replication; in a lab, override: +# Add-KdsRootKey -EffectiveTime ((Get-Date).AddHours(-10)) +``` + +### Create the gMSA + +```powershell +# from a DC, with AD PowerShell module loaded +New-ADServiceAccount -Name svc-webhookserver ` + -DNSHostName webhook01.contoso.local ` + -PrincipalsAllowedToRetrieveManagedPassword "DOMAIN\WebhookHosts" +``` + +`PrincipalsAllowedToRetrieveManagedPassword` is the security group containing the computer accounts allowed to use the gMSA. Add your webhook host(s) to that group: + +```powershell +Add-ADGroupMember -Identity 'WebhookHosts' -Members 'WEBHOOK01$' +# the host needs to reboot OR have its kerberos ticket flushed for the new group membership to apply +``` + +### Install the gMSA on the host + +On the webhook server machine itself: + +```powershell +# from elevated PowerShell, AD PowerShell module installed (RSAT) +Install-ADServiceAccount svc-webhookserver +Test-ADServiceAccount svc-webhookserver # should return True +``` + +If `Test-ADServiceAccount` returns False, check: + +- Host is in the `WebhookHosts` group (or whoever's in `PrincipalsAllowedToRetrieveManagedPassword`) +- Host has been rebooted since being added to the group +- KDS root key has had time to propagate (10 hours by default) + +### Configure the service to use it + +```powershell +# from elevated PowerShell on the webhook host +sc.exe stop WebhookServer +sc.exe config WebhookServer obj= 'CONTOSO\svc-webhookserver$' +sc.exe start WebhookServer +``` + +Note the trailing `$`. There is **no password parameter** for gMSAs. The trailing `$` is what tells the SCM "look up this account in AD as a managed service account, retrieve its password automatically." + +### Grant AD permissions + +Give the gMSA only what it needs. For a typical "reset user passwords" workload: + +```powershell +# Delegate "Reset password and force change at next logon" on a specific OU +$ou = "OU=Standard Users,DC=contoso,DC=local" +dsacls $ou /I:S /G "CONTOSO\svc-webhookserver$:CA;Reset Password;user" +dsacls $ou /I:S /G "CONTOSO\svc-webhookserver$:WP;pwdLastSet;user" +``` + +…or use the GUI Delegation of Control wizard in Active Directory Users and Computers. + +## Domain user (fallback when gMSA isn't available) + +```powershell +# 1. Create the user (one time) +New-ADUser -Name "svc-webhookserver" -SamAccountName "svc-webhookserver" ` + -AccountPassword (Read-Host -AsSecureString "password") -Enabled $true ` + -PasswordNeverExpires $true -CannotChangePassword $true + +# 2. Grant "Log on as a service" right on the host: +# secpol.msc -> Local Policies -> User Rights Assignment -> Log on as a service +# Add CONTOSO\svc-webhookserver + +# 3. Configure the service: +sc.exe config WebhookServer obj= "CONTOSO\svc-webhookserver" password= "..." +``` + +You own password rotation. When you change the password in AD, also update the service via `sc.exe config WebhookServer password= "newpw"` and restart it. + +## What changes for hooks when you switch the service account + +- **Service mode hooks** now run as the new account. PowerShell `whoami` from inside a hook will show the new identity. +- **InteractiveUser hooks stop working** if you switch off LocalSystem. Only SYSTEM can call `WTSQueryUserToken`. If you need both AD-write hooks and UI-on-desktop hooks, pick one of: + - Keep service as LocalSystem and use **SpecificUser** mode for AD-write hooks + - Switch service to a gMSA / domain user and drop UI hooks (or move them to a separate Webhook Server instance running as LocalSystem) +- **SpecificUser hooks** continue to work regardless. They use a separate `LogonUser` token per call. + +## Verifying the switch worked + +After changing the service account, restart the service and add a quick diagnostic endpoint: + +``` +slug: whoami +auth: none +executor: Windows PowerShell +inline command: whoami; whoami /groups +``` + +Hit it and verify the output matches the account you configured. The first line should be `domain\svc-webhookserver` (or `domain\machine$` for LocalSystem on a domain-joined host). diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..5fecbd1 --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,136 @@ +# Troubleshooting + +This page indexes the most common ways things go wrong, where to look, and what to do. + +## Where to look first + +| Symptom | First check | +|---|---| +| GUI shows "Disconnected" | Service running? `Get-Service WebhookServer` | +| Hook returns 404 | Slug typo, or endpoint disabled | +| Hook returns 401 | Auth header / signature mismatch | +| Hook returns 403 | IP allowlist denies the caller | +| Hook returns 200 but nothing happens | Response is the script's stdout — check exit code, stderr | +| Hook returns 502 | Script ran and exited non-zero. Body has stderr. | +| Hook returns 500 | Launch error (script not found, invalid path) | +| Hook hangs | Timeout reached, or script is waiting on stdin | +| Calc / UI doesn't appear despite InteractiveUser | See [Run As modes](runas-modes.md) — common pitfalls | + +## Where the logs are + +`C:\ProgramData\WebhookServer\logs\webhook-YYYYMMDD.log` — daily rolling, 14-day retention by default. + +Every webhook run logs: +- `[INF] Run ok exit=0 dur=ms stdout=...` on success +- `[WRN] Run non-zero exit= dur=ms stdout=... stderr=...` on script failure +- `[WRN] Run failed to launch: ` on launch failure +- `[WRN] Run timed out after s; process killed` on timeout + +The GUI's bottom panel auto-refreshes the same log file every 3 seconds. Tick the **Auto-scroll** checkbox to keep it pinned to the latest line. + +## Common issues + +### "Disconnected: Access to the path is denied" right after install + +You launched the GUI without elevation. The admin pipe ACL is `SYSTEM` + `Administrators`-full-control; UAC token splitting denies the standard token. + +**Fix in v0.1.1+**: nothing — the GUI's manifest is `requireAdministrator` and Start Menu / shortcut launches auto-elevate. + +**Fix in v0.1.0**: right-click the Start Menu shortcut → **Run as administrator**, or upgrade. + +### "Connection refused" hitting the hook URL + +Three possibilities, in order of probability: + +1. **Service stopped.** `Get-Service WebhookServer` and `Start-Service WebhookServer` if needed. +2. **Wrong port.** Default is 8080. Check **Server → Settings → HTTP port** in the GUI, or `netstat -an | findstr :8080`. +3. **Bound to a specific NIC and you're calling on another.** Check **Server → Settings → Listen on**. If "Listen on all interfaces" is unchecked and you only ticked LAN IPs, calls to `localhost` may fail. Tick `127.0.0.1` too. + +### Hook works from `localhost` but not from another machine on the LAN + +Windows Firewall. The installer doesn't add a firewall rule (intentional — you should choose your scope). Add one: + +```powershell +# from elevated PowerShell on the webhook host +New-NetFirewallRule -DisplayName "Webhook Server HTTP 8080" -Direction Inbound ` + -Action Allow -Protocol TCP -LocalPort 8080 -Profile Domain,Private +``` + +Use `-Profile Public` only if you really mean it. Better: front the server with a reverse proxy and don't expose 8080 directly. + +### `[WRN] Run … failed to launch: launch error: An error occurred trying to start process 'X'. Access is denied.` + +Likely **SpecificUser mode + `psi.UserName`** failure. Should be impossible in v0.1.1+ (we use `LogonUser` + `CreateProcessAsUser` directly). If you see this on v0.1.1, double-check the version: `Get-Item "C:\Program Files\WebhookServer\WebhookServer.Service.exe" | % VersionInfo`. + +### `[WRN] Run … failed to launch: LogonUser (DOMAIN\user) failed` + +The credentials don't authenticate. Common causes: + +- Typo in the password (paste it back into the GUI to verify; the field is plaintext for an admin user) +- Account locked / disabled / expired +- The account is denied the right logon types — check `secpol.msc` → Local Policies → User Rights Assignment → "Deny logon as a batch job" / "Deny logon locally" +- For domain accounts: the host can't reach a DC + +### `non-zero exit=-1073741502` (`0xC0000142` STATUS_DLL_INIT_FAILED) + +The new process couldn't initialize. With **InteractiveUser** mode this means we tried to open `winsta0\default` and the user's session token doesn't have access (e.g., no one's logged in). With **SpecificUser** this should not occur in v0.1.1+ — we deliberately don't set lpDesktop for that mode. + +### Hook returns 502 with empty stdout/stderr + +The script's exit was non-zero but it didn't print anything. PowerShell's `$ErrorActionPreference = 'Stop'` is your friend — turn it on at the top of the script and any cmdlet failure becomes terminating with a clear message in stderr. + +### "ServiceState: ListenerSettingsChanged" → service restart + +After saving Server Settings with a port or HTTPS change, the service stops itself so the SCM restarts it on the new bindings. The GUI briefly shows "Disconnected" then reconnects. If it doesn't reconnect within ~10 seconds: + +```powershell +Get-Service WebhookServer | Format-List Status, StartType +``` + +If the service is in `Stopped`, the SCM didn't restart it (failure-recovery only kicks in on *abnormal* termination, and a clean stop doesn't qualify). Manual: + +```powershell +Start-Service WebhookServer +``` + +### GUI editor changes don't seem to take effect + +After saving an endpoint, the service loads the new config in memory immediately — no restart needed. If a hook is mid-run when you save, that run finishes against the OLD config; the new config applies to subsequent runs. + +If the GUI's grid still shows old values, hit any other endpoint or wait for the 3-second poll to refresh the display. + +### Tray icon doesn't appear + +Check whether the GUI is running: `Get-Process WebhookServer.Gui`. If not, the tray icon doesn't exist (it's part of the GUI process). To have a persistent tray independent of the main window, leave the GUI running and minimize it — it'll hide-to-tray rather than truly close. + +To run the GUI minimized at login: create a Windows shortcut to `WebhookServer.Gui.exe`, set "Run" to "Minimized" in the shortcut properties, and put it in your user's Startup folder (`shell:startup`). The auto-elevate manifest still takes effect. + +## Getting useful logs from a script + +Inside your hook scripts, write to stderr for diagnostic info — Webhook Server logs stderr separately from stdout, and stderr is preserved even on success: + +```powershell +[Console]::Error.WriteLine("processing item $i of $total") +``` + +Or use `Write-Error` which produces non-fatal errors: + +```powershell +Write-Error "skipping bogus input" # stderr but doesn't terminate +``` + +The full stderr appears in the log line for the run, plus in the response body for sync calls. + +## Asking for help + +If you're stuck, file an issue at: + +> https://github.com/recklessop/webhook-server/issues + +Include: + +- Webhook Server version (Help → About, or the file version of the `.exe`) +- Windows version (`winver`) +- The slug + relevant bits of the endpoint config (NOT the secrets) +- The log lines for the failing run (search for the runId) +- What you expected vs. what happened diff --git a/docs/uninstalling.md b/docs/uninstalling.md new file mode 100644 index 0000000..7dc690b --- /dev/null +++ b/docs/uninstalling.md @@ -0,0 +1,90 @@ +# Uninstalling + +## TL;DR + +**Settings → Apps & features → Webhook Server → Uninstall.** Or right-click the **Uninstall Webhook Server** Start Menu shortcut. + +Your endpoints, secrets, and logs in `C:\ProgramData\WebhookServer\` are preserved by default. To wipe those too, see [Below](#wiping-config-and-logs-too). + +## What the uninstaller does + +In order: + +1. **Stops the service** (`net stop WebhookServer`). +2. **Removes the service** registration via `uninstall-service.ps1` (which calls `sc.exe delete WebhookServer`). +3. **Deletes** `C:\Program Files\WebhookServer\`. +4. **Removes** the Start Menu and (if created) Desktop shortcuts. +5. **Removes** the Programs and Features entry. + +What it **does not** touch: + +- `C:\ProgramData\WebhookServer\` (config, secrets, log files, auto-snapshots) +- Any cert in your local cert store you bound HTTPS to +- Domain accounts / gMSAs the service ran under +- Endpoints' deployed scripts, if you stored them outside the install dir + +## Wiping config and logs too + +After running the uninstaller, also remove the data root: + +```powershell +# from elevated PowerShell +Remove-Item -Recurse -Force "$env:ProgramData\WebhookServer" +``` + +This deletes: + +- `config.json` (with all your endpoints, encrypted secrets, settings) +- `backups\` (all auto-snapshots — you can't restore from these once gone) +- `logs\` (history of every webhook hit) + +There's no recovery from this. If you might want to reinstall later with the same configuration, copy `config.json` to a safe location first. Note that **secrets in the saved config can only be decrypted on the same machine** (DPAPI LocalMachine scope) — you can move the file but the bearer/HMAC/RunAs passwords inside become unrecoverable on a different host. + +## Silent uninstall + +The Programs and Features uninstaller is `unins000.exe` in the install directory: + +```powershell +# from elevated PowerShell +& "C:\Program Files\WebhookServer\unins000.exe" /VERYSILENT /SUPPRESSMSGBOXES /NORESTART +``` + +Same set of preserved/removed paths as the interactive flow. + +## Removing only the service, keeping the binaries + +If you want to keep the GUI installed but stop running the service (rare, but useful if you're testing): + +```powershell +# from elevated PowerShell +sc.exe stop WebhookServer +sc.exe delete WebhookServer +``` + +The GUI will show **Disconnected** since there's no service to talk to. Re-create the service later by running `install-service.ps1`: + +```powershell +& "C:\Program Files\WebhookServer\scripts\install-service.ps1" ` + -BinaryPath "C:\Program Files\WebhookServer\WebhookServer.Service.exe" +``` + +## Edge cases + +### "The service cannot be stopped because it has not been started." + +Harmless. The uninstaller proceeds regardless. + +### "Cannot delete: file in use" + +A GUI window or other process is holding files in `C:\Program Files\WebhookServer\` open. Close everything and re-run the uninstaller. If that fails, reboot and re-run. + +### Programs and Features entry remains after files are gone + +If you deleted `C:\Program Files\WebhookServer\` manually before running the uninstaller, `unins000.exe` is gone too and Programs and Features can't run it. Remove the orphan entry by deleting its registry key: + +```powershell +# from elevated PowerShell - dry run to confirm the key exists +Get-Item 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\{6E3B3C1A-9C20-4F50-B6A8-2B6D6D7E2F11}_is1' -ErrorAction SilentlyContinue +# if it shows up, delete it: +Remove-Item 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\{6E3B3C1A-9C20-4F50-B6A8-2B6D6D7E2F11}_is1' -Recurse +``` diff --git a/docs/upgrading.md b/docs/upgrading.md new file mode 100644 index 0000000..58ceb16 --- /dev/null +++ b/docs/upgrading.md @@ -0,0 +1,76 @@ +# Upgrading + +## TL;DR + +Download the new installer from [Releases](https://github.com/recklessop/webhook-server/releases/latest) and run it. That's it. Your config, endpoints, secrets, and logs are preserved. + +## What the upgrade does + +The Inno Setup installer detects an existing install and runs through these steps automatically: + +1. **`net stop WebhookServer`** — synchronously stops the running service so its binaries are unlocked. Blocks until the SCM reports the service is actually stopped. +2. **`taskkill /f /im WebhookServer.Gui.exe`** — closes the GUI if you left it running. Same for any orphan `WebhookServer.Service.exe` from a `deploy.ps1` dev install. +3. **Copies** the new binaries into `C:\Program Files\WebhookServer\`. Files marked `ignoreversion` so newer files always overwrite older ones, even if version metadata happens to match. +4. **Re-registers** the service via `install-service.ps1`, which detects the existing `WebhookServer` service via `Get-Service` and takes the **update** branch (changes the binary path) rather than re-creating it. Your service account choice is preserved. +5. **Starts the service**. The GUI launches if you left the post-install checkbox ticked. + +Total downtime for the service: 2–10 seconds depending on disk speed and how long the service takes to flush its log buffer. + +## What's preserved + +- `C:\ProgramData\WebhookServer\config.json` — the installer never touches this directory +- All endpoints, secrets, callback URLs, allowlists +- Bind addresses, display host, HTTPS binding settings +- Auto-snapshots in `C:\ProgramData\WebhookServer\backups\` +- Log files in `C:\ProgramData\WebhookServer\logs\` +- The Windows Service identity (LocalSystem, gMSA, domain user — whatever you configured) + +## What gets replaced + +- Everything in `C:\Program Files\WebhookServer\` — the .exe files, .dll files, the icon, `install-service.ps1`, `uninstall-service.ps1`, the bundled `README.md`, the `docs/` folder + +## Silent upgrades (Group Policy / SCCM / Intune / Ansible) + +Same as the silent install: + +```powershell +WebhookServer-Setup-X.Y.Z.exe /VERYSILENT /SUPPRESSMSGBOXES /NORESTART +``` + +The pre-install `net stop` step still fires; downtime is unchanged. + +## Rolling back to a previous version + +The installer doesn't support side-by-side versions or downgrade detection. To roll back: + +1. Uninstall the current version (Settings → Apps, or `Start Menu → Webhook Server → Uninstall`). This stops + removes the service. Your config in `C:\ProgramData\WebhookServer\` is preserved. +2. Run the older installer. + +If a config field changed semantics between versions and you ran on the new version first, the **Config Checkpoints** menu (File → Config Checkpoints) lists snapshots taken before each save. The auto-snapshot from immediately before the upgrade is the closest you'll have to your pre-upgrade config. + +## Edge cases + +### "Setup cannot continue. Please close the following applications: WebhookServer.Gui.exe" + +The taskkill step normally handles this, but if you're running an unusually slow process or if the GUI was elevated by a different user, you may see this. Close the GUI manually and click Retry. + +### Service stays in a "Stopping" state forever + +`net stop` waits up to 30 seconds for the service to stop. If a hook script hung (e.g. interactive prompt) and the service can't kill it cleanly, the SCM gives up and the install continues, but the service may end up in a bad state. Recovery: + +```powershell +# from elevated PowerShell +Stop-Service WebhookServer -Force +# if that fails: +Get-WmiObject Win32_Service -Filter "Name='WebhookServer'" | ForEach-Object { Stop-Process -Id $_.ProcessId -Force } +``` + +…then re-run the installer. + +### Upgrade from a `deploy.ps1` dev install to an installer-managed install + +The first time you run the installer on a machine that previously used `deploy.ps1`, the installer thinks it's doing a fresh install (no `Programs and Features` registry entry). It still detects the existing service and updates it cleanly, so the only visible difference is that **a Programs and Features entry now exists** for "Webhook Server" with `Justin Paul` as publisher. Future upgrades take the proper upgrade path. + +### `deploy.ps1` after an installer-managed install + +`deploy.ps1` is the dev workflow. It publishes from source and copies binaries to the same install location. Running it on top of an installer-managed install will overwrite the binaries but won't deregister the installer. If you then uninstall via Programs and Features, the uninstaller may leave files behind that `deploy.ps1` introduced. Pick one workflow and stick with it. diff --git a/installer/webhook-server.iss b/installer/webhook-server.iss index b97f5ef..e112497 100644 --- a/installer/webhook-server.iss +++ b/installer/webhook-server.iss @@ -56,6 +56,7 @@ Source: "{#RepoRoot}publish\gui\*"; DestDir: "{app}"; Flags: ignoreversion r Source: "{#RepoRoot}scripts\install-service.ps1"; DestDir: "{app}\scripts"; Flags: ignoreversion Source: "{#RepoRoot}scripts\uninstall-service.ps1"; DestDir: "{app}\scripts"; Flags: ignoreversion Source: "{#RepoRoot}README.md"; DestDir: "{app}"; Flags: ignoreversion +Source: "{#RepoRoot}docs\*"; DestDir: "{app}\docs"; Flags: ignoreversion recursesubdirs createallsubdirs Source: "{#RepoRoot}resources\webhook-server.ico"; DestDir: "{app}"; Flags: ignoreversion [Icons] diff --git a/src/WebhookServer.Gui/MainWindow.xaml b/src/WebhookServer.Gui/MainWindow.xaml index 64f037c..c42fc0e 100644 --- a/src/WebhookServer.Gui/MainWindow.xaml +++ b/src/WebhookServer.Gui/MainWindow.xaml @@ -57,6 +57,8 @@ + + diff --git a/src/WebhookServer.Gui/ViewModels/MainViewModel.cs b/src/WebhookServer.Gui/ViewModels/MainViewModel.cs index 6387b20..96190b0 100644 --- a/src/WebhookServer.Gui/ViewModels/MainViewModel.cs +++ b/src/WebhookServer.Gui/ViewModels/MainViewModel.cs @@ -290,6 +290,23 @@ private void ShowAbout() dlg.ShowDialog(); } + [RelayCommand] + private void OpenDocumentation() + { + try + { + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = "https://github.com/recklessop/webhook-server/tree/main/docs", + UseShellExecute = true, + }); + } + catch (Exception ex) + { + ShowError("Could not open documentation", ex); + } + } + [RelayCommand] private void Exit() { From e65527f316bc988fd42c92637d976daffb4c95b9 Mon Sep 17 00:00:00 2001 From: Justin Paul Date: Fri, 8 May 2026 10:38:28 -0400 Subject: [PATCH 2/5] Config Checkpoints dialog + daily auto-checkpoint; drop installer GUI launch Three fixes: 1. Config Checkpoints submenu replaced with a proper dialog. Lists checkpoints with timestamp/size/filename, has a "Take Checkpoint Now" button, and a "Roll Back" button that becomes enabled when a row is selected. The previous click-a-menu-entry-immediate-restore flow was too easy to fire by accident. 2. New CheckpointScheduler BackgroundService creates a checkpoint at midnight every day. Combined with the existing auto-on-save snapshots, this guarantees a daily rollback point even if the config wasn't edited that day. A new "create-checkpoint" admin op plus AdminPipeServer.CreateCheckpoint helper does the actual file copy; both manual (via the dialog) and the scheduler use it. 3. Installer: drop the post-install "Launch Webhook Server" wizard step. It tried to launch the GUI un-elevated, which fails because the GUI's manifest is requireAdministrator. The Start Menu shortcut handles elevation correctly, so the user can launch from there. Co-Authored-By: Claude Opus 4.7 (1M context) --- installer/webhook-server.iss | 7 +- src/WebhookServer.Core/Ipc/AdminProtocol.cs | 1 + src/WebhookServer.Gui/MainWindow.xaml | 20 +--- src/WebhookServer.Gui/MainWindow.xaml.cs | 5 - .../Services/AdminPipeClient.cs | 3 + .../ViewModels/ConfigCheckpointsViewModel.cs | 94 +++++++++++++++++++ .../ViewModels/MainViewModel.cs | 39 ++------ .../Views/ConfigCheckpointsDialog.xaml | 51 ++++++++++ .../Views/ConfigCheckpointsDialog.xaml.cs | 19 ++++ src/WebhookServer.Service/AdminPipeServer.cs | 36 +++++++ .../CheckpointScheduler.cs | 50 ++++++++++ src/WebhookServer.Service/Program.cs | 1 + 12 files changed, 269 insertions(+), 57 deletions(-) create mode 100644 src/WebhookServer.Gui/ViewModels/ConfigCheckpointsViewModel.cs create mode 100644 src/WebhookServer.Gui/Views/ConfigCheckpointsDialog.xaml create mode 100644 src/WebhookServer.Gui/Views/ConfigCheckpointsDialog.xaml.cs create mode 100644 src/WebhookServer.Service/CheckpointScheduler.cs diff --git a/installer/webhook-server.iss b/installer/webhook-server.iss index e112497..89ab350 100644 --- a/installer/webhook-server.iss +++ b/installer/webhook-server.iss @@ -69,9 +69,10 @@ Filename: "powershell.exe"; \ Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{app}\scripts\install-service.ps1"" -BinaryPath ""{app}\{#ServiceExeName}"""; \ StatusMsg: "Installing Windows Service..."; \ Flags: runhidden -Filename: "{app}\{#AppExeName}"; \ - Description: "Launch {#AppName}"; \ - Flags: postinstall nowait skipifsilent +; No post-install GUI launch: the GUI is requireAdministrator and the launch +; from the installer wizard ends up un-elevated for the post-install user, so +; it would just fail to connect to the admin pipe. The Start Menu shortcut +; handles the elevation correctly via the embedded manifest. [UninstallRun] Filename: "powershell.exe"; \ diff --git a/src/WebhookServer.Core/Ipc/AdminProtocol.cs b/src/WebhookServer.Core/Ipc/AdminProtocol.cs index 5ca1c4e..6977ccb 100644 --- a/src/WebhookServer.Core/Ipc/AdminProtocol.cs +++ b/src/WebhookServer.Core/Ipc/AdminProtocol.cs @@ -26,6 +26,7 @@ public static class AdminOps public const string ListBackups = "list-backups"; public const string RestoreBackup = "restore-backup"; public const string ImportConfig = "import-config"; + public const string CreateCheckpoint = "create-checkpoint"; } public sealed class BackupEntry diff --git a/src/WebhookServer.Gui/MainWindow.xaml b/src/WebhookServer.Gui/MainWindow.xaml index c42fc0e..466c38a 100644 --- a/src/WebhookServer.Gui/MainWindow.xaml +++ b/src/WebhookServer.Gui/MainWindow.xaml @@ -29,25 +29,7 @@ - - - - - + diff --git a/src/WebhookServer.Gui/MainWindow.xaml.cs b/src/WebhookServer.Gui/MainWindow.xaml.cs index de2a239..9f387ff 100644 --- a/src/WebhookServer.Gui/MainWindow.xaml.cs +++ b/src/WebhookServer.Gui/MainWindow.xaml.cs @@ -53,9 +53,4 @@ private void OnRowDoubleClick(object sender, MouseButtonEventArgs e) vm.EditEndpointCommand.Execute(null); } - private async void OnBackupsSubmenuOpened(object sender, RoutedEventArgs e) - { - if (DataContext is MainViewModel vm) - await vm.RefreshBackupsCommand.ExecuteAsync(null); - } } diff --git a/src/WebhookServer.Gui/Services/AdminPipeClient.cs b/src/WebhookServer.Gui/Services/AdminPipeClient.cs index 4ce804e..300d3f0 100644 --- a/src/WebhookServer.Gui/Services/AdminPipeClient.cs +++ b/src/WebhookServer.Gui/Services/AdminPipeClient.cs @@ -100,4 +100,7 @@ public Task RestoreBackupAsync(string fileName, CancellationToken public Task ImportConfigAsync(ServerConfig config, CancellationToken ct = default) => InvokeAsync(AdminOps.ImportConfig, config, ct); + + public Task CreateCheckpointAsync(CancellationToken ct = default) => + InvokeAsync(AdminOps.CreateCheckpoint, null, ct); } diff --git a/src/WebhookServer.Gui/ViewModels/ConfigCheckpointsViewModel.cs b/src/WebhookServer.Gui/ViewModels/ConfigCheckpointsViewModel.cs new file mode 100644 index 0000000..72e6797 --- /dev/null +++ b/src/WebhookServer.Gui/ViewModels/ConfigCheckpointsViewModel.cs @@ -0,0 +1,94 @@ +using System.Collections.ObjectModel; +using System.Runtime.Versioning; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using WebhookServer.Core.Ipc; +using WebhookServer.Gui.Services; + +namespace WebhookServer.Gui.ViewModels; + +[SupportedOSPlatform("windows")] +public sealed partial class ConfigCheckpointsViewModel : ObservableObject +{ + private readonly AdminPipeClient _client; + + public ObservableCollection Checkpoints { get; } = new(); + + [ObservableProperty] private BackupEntry? _selected; + [ObservableProperty] private string _statusMessage = ""; + + public ConfigCheckpointsViewModel(AdminPipeClient client) + { + _client = client; + } + + [RelayCommand] + public async Task RefreshAsync() + { + try + { + var list = await _client.ListBackupsAsync().ConfigureAwait(false); + Application.Current.Dispatcher.Invoke(() => + { + Checkpoints.Clear(); + foreach (var b in list) Checkpoints.Add(b); + StatusMessage = list.Count == 0 + ? "No checkpoints yet. Save the config or click Take Checkpoint Now." + : $"{list.Count} checkpoint{(list.Count == 1 ? "" : "s")}."; + }); + } + catch (Exception ex) + { + Application.Current.Dispatcher.Invoke(() => StatusMessage = $"Could not load: {ex.Message}"); + } + } + + [RelayCommand] + private async Task TakeCheckpointAsync() + { + try + { + var entry = await _client.CreateCheckpointAsync().ConfigureAwait(false); + await RefreshAsync().ConfigureAwait(false); + if (entry is not null) + { + Application.Current.Dispatcher.Invoke(() => + { + Selected = Checkpoints.FirstOrDefault(c => c.FileName == entry.FileName); + StatusMessage = $"Created {entry.FileName}"; + }); + } + } + catch (Exception ex) + { + Application.Current.Dispatcher.Invoke(() => + MessageBox.Show(ex.Message, "Take checkpoint failed", MessageBoxButton.OK, MessageBoxImage.Error)); + } + } + + [RelayCommand] + private async Task RollbackAsync() + { + if (Selected is null) return; + + var ok = MessageBox.Show( + $"Roll the configuration back to the checkpoint from {Selected.SavedAt.ToLocalTime():yyyy-MM-dd HH:mm:ss}?\n\nThe current configuration is automatically saved as a new checkpoint first, so you can roll forward again.", + "Confirm rollback", + MessageBoxButton.OKCancel, + MessageBoxImage.Warning); + if (ok != MessageBoxResult.OK) return; + + try + { + await _client.RestoreBackupAsync(Selected.FileName).ConfigureAwait(false); + await RefreshAsync().ConfigureAwait(false); + Application.Current.Dispatcher.Invoke(() => + StatusMessage = $"Rolled back to {Selected!.FileName}."); + } + catch (Exception ex) + { + Application.Current.Dispatcher.Invoke(() => + MessageBox.Show(ex.Message, "Rollback failed", MessageBoxButton.OK, MessageBoxImage.Error)); + } + } +} diff --git a/src/WebhookServer.Gui/ViewModels/MainViewModel.cs b/src/WebhookServer.Gui/ViewModels/MainViewModel.cs index 96190b0..5e944b6 100644 --- a/src/WebhookServer.Gui/ViewModels/MainViewModel.cs +++ b/src/WebhookServer.Gui/ViewModels/MainViewModel.cs @@ -175,39 +175,18 @@ private async Task ToggleEnabledAsync(EndpointConfig? ep) } } - [ObservableProperty] private System.Collections.ObjectModel.ObservableCollection _backups = new(); - [RelayCommand] - private async Task RefreshBackupsAsync() + private void ShowConfigCheckpoints() { - try + var dlg = new Views.ConfigCheckpointsDialog { - var list = await _client.ListBackupsAsync().ConfigureAwait(false); - Application.Current.Dispatcher.Invoke(() => - { - Backups.Clear(); - foreach (var b in list) Backups.Add(b); - }); - } - catch { /* ignore - checkpoint listing isn't critical */ } - } - - [RelayCommand] - private async Task RestoreBackupAsync(BackupEntry? entry) - { - if (entry is null) return; - var ok = MessageBox.Show( - $"Restore the configuration from the checkpoint taken at {entry.SavedAt:yyyy-MM-dd HH:mm}?\n\nThe current configuration is automatically saved as a new checkpoint first, so you can roll forward again.", - "Restore checkpoint", - MessageBoxButton.OKCancel, - MessageBoxImage.Question); - if (ok != MessageBoxResult.OK) return; - try - { - await _client.RestoreBackupAsync(entry.FileName).ConfigureAwait(false); - await RefreshAsync().ConfigureAwait(false); - } - catch (Exception ex) { ShowError("Restore failed", ex); } + Owner = Application.Current.MainWindow, + DataContext = new ConfigCheckpointsViewModel(_client), + }; + dlg.ShowDialog(); + // After the dialog closes, the live config may have changed via rollback, + // so refresh the main grid. + _ = RefreshAsync(); } [RelayCommand] diff --git a/src/WebhookServer.Gui/Views/ConfigCheckpointsDialog.xaml b/src/WebhookServer.Gui/Views/ConfigCheckpointsDialog.xaml new file mode 100644 index 0000000..a4f70c8 --- /dev/null +++ b/src/WebhookServer.Gui/Views/ConfigCheckpointsDialog.xaml @@ -0,0 +1,51 @@ + + + + A checkpoint is a snapshot of config.json taken before each save and once a day at midnight. + Pick one and click Roll Back to restore it. The current configuration is automatically saved + as a new checkpoint before any rollback, so you can always roll forward again. + + + +