diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..2646469
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,27 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+
+jobs:
+ build:
+ runs-on: windows-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '8.0.x'
+
+ - name: Restore
+ run: dotnet restore WebhookServer.sln
+
+ - name: Build
+ run: dotnet build WebhookServer.sln -c Release --no-restore
+
+ - name: Test
+ run: dotnet test WebhookServer.sln -c Release --no-build --verbosity normal
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..b7e6750
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,71 @@
+name: Release
+
+on:
+ push:
+ tags:
+ - 'v*'
+ workflow_dispatch:
+ inputs:
+ version:
+ description: 'Version to build (e.g. 0.1.0). Defaults to Directory.Build.props.'
+ required: false
+
+jobs:
+ build-installer:
+ runs-on: windows-latest
+ permissions:
+ contents: write # needed to create releases / upload assets
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '8.0.x'
+
+ - name: Resolve version
+ id: ver
+ shell: pwsh
+ run: |
+ if ('${{ github.event_name }}' -eq 'push') {
+ $v = '${{ github.ref_name }}'.TrimStart('v')
+ } elseif ('${{ inputs.version }}') {
+ $v = '${{ inputs.version }}'
+ } else {
+ [xml]$p = Get-Content Directory.Build.props
+ $v = $p.Project.PropertyGroup.Version
+ }
+ "version=$v" | Out-File $env:GITHUB_OUTPUT -Append
+ Write-Host "Building version $v"
+
+ - name: Restore + test
+ run: |
+ dotnet restore WebhookServer.sln
+ dotnet test WebhookServer.sln -c Release
+
+ - name: Install Inno Setup
+ shell: pwsh
+ run: |
+ choco install innosetup --no-progress -y
+ Write-Host "ISCC at: $((Get-Command iscc).Path)"
+
+ - name: Build installer
+ shell: pwsh
+ run: ./scripts/build-installer.ps1 -VersionOverride ${{ steps.ver.outputs.version }}
+
+ - name: Upload installer artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: WebhookServer-Setup-${{ steps.ver.outputs.version }}
+ path: dist/WebhookServer-Setup-*.exe
+
+ - name: Create GitHub Release
+ if: startsWith(github.ref, 'refs/tags/v')
+ uses: softprops/action-gh-release@v2
+ with:
+ name: Webhook Server ${{ steps.ver.outputs.version }}
+ tag_name: ${{ github.ref_name }}
+ draft: false
+ prerelease: ${{ startsWith(steps.ver.outputs.version, '0.') }}
+ files: dist/WebhookServer-Setup-*.exe
+ generate_release_notes: true
diff --git a/.github/workflows/wiki-sync.yml b/.github/workflows/wiki-sync.yml
new file mode 100644
index 0000000..349fefa
--- /dev/null
+++ b/.github/workflows/wiki-sync.yml
@@ -0,0 +1,27 @@
+name: Sync Wiki
+
+on:
+ push:
+ branches: [main]
+ paths:
+ - 'docs/**'
+ - 'scripts/sync-wiki.ps1'
+ - '.github/workflows/wiki-sync.yml'
+ workflow_dispatch:
+
+jobs:
+ sync:
+ runs-on: windows-latest
+ permissions:
+ contents: write
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Sync docs/ to GitHub wiki
+ shell: pwsh
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ $repo = '${{ github.repository }}'
+ $wikiUrl = "https://x-access-token:$env:GH_TOKEN@github.com/$repo.wiki.git"
+ ./scripts/sync-wiki.ps1 -WikiUrl $wikiUrl
diff --git a/Directory.Build.props b/Directory.Build.props
index 65b6f43..1537f9d 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -1,7 +1,7 @@
- 0.1.0
+ 0.1.2Justin PaulJustin PaulWebhook Server
diff --git a/README.md b/README.md
index 3788222..0d450df 100644
--- a/README.md
+++ b/README.md
@@ -1,111 +1,91 @@
-# 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:
+
+- [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
+
+Recipes:
+
+- [Zerto failover post-script → DNS + service checks](docs/recipes/zerto-pre-post-scripts.md) ← **canonical use case**
+- [GitHub-style HMAC-signed webhook](docs/recipes/github-style-hmac.md)
+- [Pop UI on the user's desktop](docs/recipes/ui-on-desktop.md)
+
+A ready-to-drop-in Zerto-side script is included at [`scripts/examples/zerto-post-failover.ps1`](scripts/examples/zerto-post-failover.ps1).
## Requirements
-- Windows 10 / 11 or Windows Server 2019+
-- .NET 8 SDK to build, .NET 8 Runtime (or self-contained publish) to run
-- Administrator rights to install the service and to run the GUI (the admin named pipe is ACL'd to SYSTEM + Administrators)
+- Windows 10 / 11 / Server 2019+
+- x64
+- .NET 8 SDK to build (the released installer includes everything else)
-## Building (on Windows)
+## Building from source
```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
+git clone https://github.com/recklessop/webhook-server.git
+cd webhook-server
+
+# Dev install (publishes + copies to C:\Program Files\WebhookServer + registers service)
+powershell -ExecutionPolicy Bypass -File scripts\deploy.ps1
+
+# Or build the installer locally (requires Inno Setup 6: winget install JRSoftware.InnoSetup)
+powershell -ExecutionPolicy Bypass -File scripts\build-installer.ps1
```
-## 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.
-
-> 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.
-
-## Configuration
-
-The service reads `C:\ProgramData\WebhookServer\config.json`. Edit it through the GUI rather than by hand — the GUI handles DPAPI encryption of secrets and validation of IP allowlist entries.
-
-## Out of scope for v1
-
-- Importing/exporting config across machines (DPAPI LocalMachine scope ties decryption to the host).
-- Per-endpoint rate limiting.
-- Multi-user RBAC for the GUI.
-- Auto-update.
-
## License
-Not yet chosen.
+TBD.
diff --git a/docs/README.md b/docs/README.md
new file mode 100644
index 0000000..c4e5630
--- /dev/null
+++ b/docs/README.md
@@ -0,0 +1,32 @@
+# 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 failover post-script → DNS + service checks](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 failover post-script → DNS + service checks](recipes/zerto-pre-post-scripts.md) ← canonical use case
+- [GitHub-style HMAC-signed webhook](recipes/github-style-hmac.md)
+- [Pop UI on the user's desktop](recipes/ui-on-desktop.md)
+
+The flagship Zerto recipe also ships with a **ready-to-use Zerto-side post-script** at [`scripts/examples/zerto-post-failover.ps1`](../scripts/examples/zerto-post-failover.ps1).
+
+## 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/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..eb16c19
--- /dev/null
+++ b/docs/recipes/zerto-pre-post-scripts.md
@@ -0,0 +1,243 @@
+# Recipe: Zerto failover post-script → DNS update + service checks
+
+This is the canonical reason Webhook Server exists.
+
+When Zerto fails a VM over from production to DR, the VM boots fine — but **the things around it** often need attention: DNS records still point at the production IP, dependent services need to be checked, on-call needs a heads-up. Zerto pre/post scripts run on the **Zerto Virtual Manager**, not on a domain controller and not necessarily with admin rights to the things that need fixing. So you want a single webhook URL that the post-script hits, and a Windows host on the DR side that does the actual work with the right identity.
+
+## What we're building
+
+Zerto's post-recovery script (a one-shot PowerShell file pointing at curl) calls `http://webhook.dr.contoso.local:8080/hook/post-failover` with a JSON body identifying the VPG and operation. The Webhook Server, running on a DR-side Windows host as a gMSA with delegated AD/DNS rights, runs PowerShell that:
+
+1. Updates DNS A records to point the failed-over hostnames at their DR IPs
+2. Waits for the failed-over VM to come up (ping + WinRM probe)
+3. Connects to the VM via PowerShell remoting and starts/checks critical services
+4. Sends a Teams notification with the result
+
+The endpoint is **Async** so the Zerto script returns in milliseconds — no risk of timing out Zerto's failover sequence even if the actions take minutes. The script's full output ends up in the webhook log and (optionally) in an outbound callback.
+
+## Why curl and not Invoke-WebRequest?
+
+Zerto's PowerShell runner is intentionally minimal — many environments run an older Windows on the ZVM and don't have full PowerShell modules installed. `curl.exe` ships with Windows 10 1803+ and Server 2019+ and works without any modules. Plus, calling an HTTP endpoint with `curl.exe` doesn't depend on the version of `Invoke-WebRequest` shipped with the host's PowerShell.
+
+## 1. The Zerto post-script (client side)
+
+A ready-to-use script ships in this repo at [`scripts/examples/zerto-post-failover.ps1`](../../scripts/examples/zerto-post-failover.ps1). Copy it to the ZVM, edit `$WebhookUrl` and the bearer-token path at the top, and wire it into the VPG:
+
+> **VPG settings → Recovery → Scripts → Post-Recovery Script**
+> Path: `C:\Scripts\zerto-post-failover.ps1`
+> Parameters: *(leave empty)*
+
+The script is ~50 lines and only depends on `curl.exe` + a token file readable by the ZVM service account.
+
+The flow:
+
+```
+Zerto VPG failover starts
+ |
+ +-- VM is brought up at DR site
+ |
+ +-- Zerto post-script fires:
+ | curl POST http://webhook.dr/hook/post-failover (async, returns 202 in ~50ms)
+ |
+ +-- Zerto sees success, finishes the failover and reports done
+ |
+ (meanwhile, on the webhook server)
+ |
+ running PowerShell for several minutes:
+ - update DNS
+ - wait for VM ready
+ - check services on VM
+ - notify Teams
+```
+
+## 2. The server-side script (does the actual work)
+
+Save this on the webhook host as `C:\Scripts\post-failover-handler.ps1`:
+
+```powershell
+[CmdletBinding()]
+param()
+$ErrorActionPreference = 'Stop'
+
+$body = $input | ConvertFrom-Json
+
+# ---------- environment specifics; edit for your site ----------
+$dnsServer = 'dc01.contoso.local'
+$forwardZone = 'contoso.local'
+$teamsWebhook = 'https://contoso.webhook.office.com/...'
+$drIpMap = @{
+ 'app01' = '10.42.10.11'
+ 'app02' = '10.42.10.12'
+ 'db01' = '10.42.10.21'
+}
+$serviceMap = @{
+ 'app01' = @('W3SVC','MyAppSvc')
+ 'app02' = @('W3SVC','MyAppSvc')
+ 'db01' = @('MSSQLSERVER','SQLAgent')
+}
+# ---------------------------------------------------------------
+
+# Default the VM list to "all VMs we know about" if the post-script didn't
+# tell us, so the same handler works without having to embed the VM list in
+# every Zerto post-script.
+$vms = if ($body.vms) { $body.vms } else { $drIpMap.Keys }
+
+$summary = @()
+
+foreach ($vm in $vms) {
+ if (-not $drIpMap.ContainsKey($vm)) {
+ $summary += "skip $vm (no DR IP mapping in handler)"
+ continue
+ }
+ $ip = $drIpMap[$vm]
+
+ # 1. DNS - delete + re-add the A record
+ try {
+ $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 $ip -ComputerName $dnsServer -TimeToLive 00:05:00
+ $summary += "dns $vm -> $ip"
+ } catch {
+ $summary += "DNS! $vm $($_.Exception.Message)"
+ continue
+ }
+
+ # 2. Wait for the VM to be reachable (up to 5 minutes)
+ $deadline = (Get-Date).AddMinutes(5)
+ $reachable = $false
+ while ((Get-Date) -lt $deadline) {
+ if (Test-Connection -ComputerName $ip -Count 1 -Quiet -ErrorAction SilentlyContinue) {
+ try {
+ # Quick WinRM probe; succeeds when the VM has finished booting
+ Invoke-Command -ComputerName $ip -ScriptBlock { $true } -ErrorAction Stop | Out-Null
+ $reachable = $true
+ break
+ } catch { Start-Sleep -Seconds 10 }
+ } else {
+ Start-Sleep -Seconds 10
+ }
+ }
+ if (-not $reachable) {
+ $summary += "wait! $vm not reachable after 5 minutes"
+ continue
+ }
+
+ # 3. Check + start critical services on the VM
+ if ($serviceMap.ContainsKey($vm)) {
+ $svcReport = Invoke-Command -ComputerName $ip -ArgumentList @(,$serviceMap[$vm]) -ScriptBlock {
+ param($services)
+ $report = @()
+ foreach ($s in $services) {
+ $svc = Get-Service -Name $s -ErrorAction SilentlyContinue
+ if (-not $svc) { $report += "$s : missing"; continue }
+ if ($svc.Status -ne 'Running') {
+ Start-Service $s
+ Start-Sleep -Seconds 2
+ $svc.Refresh()
+ }
+ $report += "$s : $($svc.Status)"
+ }
+ return $report
+ }
+ $summary += "svc $vm : $($svcReport -join ', ')"
+ } else {
+ $summary += "svc $vm (no services configured)"
+ }
+}
+
+# 4. Notify Teams
+$teamsBody = @{
+ text = "Webhook post-failover for VPG **$($body.vpg)**:`n" + ($summary -join "`n")
+} | ConvertTo-Json
+try {
+ Invoke-RestMethod -Uri $teamsWebhook -Method POST -ContentType 'application/json' -Body $teamsBody | Out-Null
+} catch {
+ $summary += "teams! notification failed: $($_.Exception.Message)"
+}
+
+# Return the summary so it shows up in the webhook log + outbound callback
+$summary -join "`n"
+```
+
+Two things to call out:
+
+- **PowerShell remoting to the VM** uses the gMSA's network identity (or whoever the service runs as). Make sure the gMSA / service account can `Invoke-Command` to the failed-over hosts — usually that means the account is a local admin on the target VMs, or you've configured constrained delegation.
+- **WinRM** must be enabled on the failed-over VMs for the remoting calls to work. `Enable-PSRemoting` is the simplest, but most prod environments configure WinRM via Group Policy.
+
+## 3. Configure the endpoint in the GUI
+
+**File → New endpoint:**
+
+| Section | Setting | Value |
+|---|---|---|
+| Identity | Slug | `post-failover` |
+| Identity | Description | "Zerto post-recovery: DNS + service checks" |
+| Auth | Mode | **Bearer** |
+| Auth | Bearer secret | generate a 32-byte random string; copy it for the Zerto script's token file |
+| Allowed clients | (one per line) | `10.0.0.0/8` *(your ZVM's network)* |
+| Executor | Type | **Windows PowerShell** |
+| Executor | Script path | `C:\Scripts\post-failover-handler.ps1` |
+| Data passing | JSON body to stdin | ✓ |
+| Run as | Identity | **Service** if the service runs under a gMSA with the right rights, otherwise **SpecificUser** with a delegated account |
+| Response | Mode | **Async** ← critical: this is what makes the Zerto script non-blocking |
+| Response | Timeout (sec) | `600` *(this is the cap on the long-running handler script, not the Zerto-facing response)* |
+| Response | Fail on non-zero exit | unticked *(async hooks have no caller to receive a 502)* |
+
+Save. Right-click the row → **Copy URL** to grab `http://webhook.dr.contoso.local:8080/hook/post-failover` and paste it into `$WebhookUrl` at the top of the Zerto-side script.
+
+> **Why Bearer instead of HMAC?** Both work. Bearer is simpler — drop the token in a file on the ZVM that's readable by the ZVM service account and you're done. HMAC requires the Zerto-side script to compute a signature, which is doable but adds a few lines of code. Pick what fits your environment.
+
+## 4. Wire up the bearer token
+
+Place the bearer token in a file the ZVM service account can read (and nobody else):
+
+```powershell
+# on the ZVM, from elevated PowerShell
+$token = (New-Guid).ToString('N') # or paste the value from the GUI
+$tokenPath = 'C:\ProgramData\Zerto\webhook-token.txt'
+$token | Out-File -LiteralPath $tokenPath -Encoding utf8 -NoNewline
+icacls $tokenPath /inheritance:r /grant 'NT SERVICE\Zerto Online Services:R' 'BUILTIN\Administrators:F' /T
+```
+
+Adjust the service principal name to whatever Zerto runs as on your version. The script reads from this path automatically; no change needed in the script itself.
+
+## 5. Test before going live
+
+In a maintenance window, fire the webhook by hand:
+
+```powershell
+# from any machine that can reach the webhook server
+$body = @{
+ operation = 'test'
+ vpg = 'SmokeTest'
+ timestamp = (Get-Date).ToUniversalTime().ToString('o')
+} | ConvertTo-Json -Compress
+
+curl.exe --silent --show-error --max-time 10 -X POST `
+ -H "Authorization: Bearer paste-the-token" `
+ -H "Content-Type: application/json" `
+ -d $body `
+ http://webhook.dr.contoso.local:8080/hook/post-failover
+```
+
+You'll get back `{"runId":"…","accepted":true}` immediately. Open the Webhook Server GUI and watch the log panel — within 30 seconds or so you'll see lines for the run. Confirm DNS records updated, services on each VM ended in `Running`, and the Teams notification arrived.
+
+## Variations
+
+### Different actions for failover vs. failback
+
+Pass an `operation` field in the body and branch on it. The Zerto-side script already sends `operation = 'failover'`. Add a separate post-failback script (or detect from `$env:ZertoOperationType`) that sends `operation = 'failback'` and have the handler revert DNS to production IPs.
+
+### Per-VPG endpoints
+
+If you want fine-grained access control or different actions per VPG, create one endpoint per VPG (`post-failover-app`, `post-failover-db`, …) and give each its own bearer token. The GUI handles dozens of endpoints fine.
+
+### Audit trail to a SIEM
+
+Each endpoint can have an outbound **Callback** URL. Configure it with your SIEM's HTTP collector + an HMAC secret, and every run produces a JSON record with runId, exit code, duration, stdout, and stderr — perfect for compliance.
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
new file mode 100644
index 0000000..562546b
--- /dev/null
+++ b/installer/webhook-server.iss
@@ -0,0 +1,124 @@
+; Inno Setup script for Webhook Server.
+;
+; Build: iscc /DAppVersion=0.1.0 webhook-server.iss
+; Output: ..\dist\WebhookServer-Setup-{AppVersion}.exe
+;
+; The installer copies published binaries to {pf}\WebhookServer, installs the
+; Windows Service via install-service.ps1 post-install, and removes the service
+; via uninstall-service.ps1 pre-uninstall. Start Menu gets a single GUI shortcut.
+
+#ifndef AppVersion
+ #define AppVersion "0.1.0"
+#endif
+
+#define AppName "Webhook Server"
+#define AppPublisher "Justin Paul"
+#define AppURL "https://jpaul.me"
+#define AppExeName "WebhookServer.Gui.exe"
+#define ServiceExeName "WebhookServer.Service.exe"
+#define ServiceName "WebhookServer"
+#define RepoRoot "..\"
+
+[Setup]
+AppId={{6E3B3C1A-9C20-4F50-B6A8-2B6D6D7E2F11}
+AppName={#AppName}
+AppVersion={#AppVersion}
+AppPublisher={#AppPublisher}
+AppPublisherURL={#AppURL}
+AppSupportURL=https://github.com/recklessop/webhook-server
+AppUpdatesURL=https://github.com/recklessop/webhook-server/releases
+DefaultDirName={autopf}\WebhookServer
+DefaultGroupName={#AppName}
+DisableProgramGroupPage=yes
+OutputBaseFilename=WebhookServer-Setup-{#AppVersion}
+OutputDir={#RepoRoot}dist
+SetupIconFile={#RepoRoot}resources\webhook-server.ico
+UninstallDisplayIcon={app}\{#AppExeName}
+PrivilegesRequired=admin
+ArchitecturesAllowed=x64compatible
+ArchitecturesInstallIn64BitMode=x64compatible
+Compression=lzma2/max
+SolidCompression=yes
+WizardStyle=modern
+VersionInfoVersion={#AppVersion}.0
+VersionInfoCompany={#AppPublisher}
+VersionInfoProductName={#AppName}
+
+[Languages]
+Name: "english"; MessagesFile: "compiler:Default.isl"
+
+[Tasks]
+Name: "desktopicon"; Description: "Create a &desktop shortcut"; GroupDescription: "Additional shortcuts:"; Flags: unchecked
+
+[Files]
+Source: "{#RepoRoot}publish\service\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
+Source: "{#RepoRoot}publish\gui\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
+Source: "{#RepoRoot}scripts\install-service.ps1"; DestDir: "{app}\scripts"; Flags: ignoreversion
+Source: "{#RepoRoot}scripts\uninstall-service.ps1"; DestDir: "{app}\scripts"; Flags: ignoreversion
+Source: "{#RepoRoot}scripts\examples\*"; DestDir: "{app}\scripts\examples"; Flags: ignoreversion recursesubdirs createallsubdirs
+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]
+Name: "{group}\{#AppName}"; Filename: "{app}\{#AppExeName}"; IconFilename: "{app}\webhook-server.ico"
+Name: "{group}\Uninstall {#AppName}"; Filename: "{uninstallexe}"
+Name: "{commondesktop}\{#AppName}"; Filename: "{app}\{#AppExeName}"; IconFilename: "{app}\webhook-server.ico"; Tasks: desktopicon
+
+[Run]
+Filename: "powershell.exe"; \
+ Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{app}\scripts\install-service.ps1"" -BinaryPath ""{app}\{#ServiceExeName}"""; \
+ StatusMsg: "Installing Windows Service..."; \
+ Flags: runhidden
+; Post-install GUI launch. The GUI's app.manifest is requireAdministrator,
+; so launching with shellexec (ShellExecute) honors the manifest and triggers
+; a clean UAC prompt. Using plain CreateProcess via the default Run path
+; would skip the manifest and result in an un-elevated GUI that cannot connect
+; to the admin pipe.
+Filename: "{app}\{#AppExeName}"; \
+ Description: "Launch {#AppName}"; \
+ Flags: postinstall nowait shellexec skipifsilent
+
+[UninstallRun]
+Filename: "powershell.exe"; \
+ Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{app}\scripts\uninstall-service.ps1"""; \
+ Flags: runhidden; \
+ RunOnceId: "RemoveWebhookService"
+
+[Code]
+function ServiceExists(): Boolean;
+var
+ ResultCode: Integer;
+begin
+ // sc.exe query returns 0 when the service exists, 1060 when it does not.
+ Exec(ExpandConstant('{sys}\sc.exe'), 'query WebhookServer', '', SW_HIDE,
+ ewWaitUntilTerminated, ResultCode);
+ Result := (ResultCode = 0);
+end;
+
+function PrepareToInstall(var NeedsRestart: Boolean): String;
+var
+ ResultCode: Integer;
+begin
+ Result := '';
+
+ // 1. If the service exists, stop it so its binaries are unlocked before file
+ // copy. net stop is synchronous (blocks until the service is actually
+ // stopped), unlike sc stop which is fire-and-forget. Non-zero exit -
+ // already stopped, missing, dependency error - we ignore; the file copy
+ // will fail loudly if the binaries are still locked.
+ if ServiceExists() then
+ begin
+ WizardForm.PreparingLabel.Caption := 'Stopping the WebhookServer service...';
+ Exec(ExpandConstant('{sys}\net.exe'), 'stop WebhookServer', '', SW_HIDE,
+ ewWaitUntilTerminated, ResultCode);
+ end;
+
+ // 2. Kill any running GUI / tray instances so their binaries are unlocked too.
+ // /f forces termination, /im matches by image name, "*" wildcard would be
+ // risky so we name them explicitly.
+ Exec(ExpandConstant('{sys}\taskkill.exe'), '/f /im WebhookServer.Gui.exe',
+ '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
+ Exec(ExpandConstant('{sys}\taskkill.exe'), '/f /im WebhookServer.Service.exe',
+ '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
+end;
diff --git a/resources/webhook-server.ico b/resources/webhook-server.ico
new file mode 100644
index 0000000..dd4e491
Binary files /dev/null and b/resources/webhook-server.ico differ
diff --git a/resources/webhook-server.png b/resources/webhook-server.png
new file mode 100644
index 0000000..be9ef7d
Binary files /dev/null and b/resources/webhook-server.png differ
diff --git a/scripts/build-installer.ps1 b/scripts/build-installer.ps1
new file mode 100644
index 0000000..af8e060
--- /dev/null
+++ b/scripts/build-installer.ps1
@@ -0,0 +1,68 @@
+<#
+.SYNOPSIS
+ End-to-end installer build: publish service + GUI, then run Inno Setup
+ to produce dist/WebhookServer-Setup-{version}.exe.
+
+.DESCRIPTION
+ Reads the version from Directory.Build.props. Requires Inno Setup 6 (ISCC.exe)
+ on PATH or in the standard install location. CI runs this same script after
+ setup-dotnet + winget install Inno Setup.
+#>
+[CmdletBinding()]
+param(
+ [string]$Configuration = 'Release',
+ [string]$VersionOverride
+)
+
+$ErrorActionPreference = 'Stop'
+$repoRoot = Split-Path -Parent $PSScriptRoot
+
+function Get-RepoVersion {
+ $propsPath = Join-Path $repoRoot 'Directory.Build.props'
+ [xml]$props = Get-Content $propsPath
+ return $props.Project.PropertyGroup.Version
+}
+
+function Find-InnoCompiler {
+ $candidates = @(
+ 'ISCC.exe', # on PATH
+ 'C:\Program Files (x86)\Inno Setup 6\ISCC.exe',
+ 'C:\Program Files\Inno Setup 6\ISCC.exe'
+ )
+ foreach ($c in $candidates) {
+ $cmd = Get-Command $c -ErrorAction SilentlyContinue
+ if ($cmd) { return $cmd.Path }
+ if (Test-Path $c) { return $c }
+ }
+ throw "Inno Setup compiler not found. Install with: winget install JRSoftware.InnoSetup"
+}
+
+$version = if ($VersionOverride) { $VersionOverride } else { Get-RepoVersion }
+Write-Host "Building Webhook Server installer v$version" -ForegroundColor Cyan
+
+# 1. Publish both projects.
+$publishSvc = Join-Path $repoRoot 'publish\service'
+$publishGui = Join-Path $repoRoot 'publish\gui'
+Remove-Item -Recurse -Force $publishSvc, $publishGui -ErrorAction SilentlyContinue
+
+& dotnet publish (Join-Path $repoRoot 'src\WebhookServer.Service\WebhookServer.Service.csproj') `
+ -c $Configuration -r win-x64 --self-contained false -o $publishSvc | Out-Host
+if ($LASTEXITCODE -ne 0) { throw 'service publish failed' }
+
+& dotnet publish (Join-Path $repoRoot 'src\WebhookServer.Gui\WebhookServer.Gui.csproj') `
+ -c $Configuration -r win-x64 --self-contained false -o $publishGui | Out-Host
+if ($LASTEXITCODE -ne 0) { throw 'GUI publish failed' }
+
+# 2. Compile installer.
+$iscc = Find-InnoCompiler
+$iss = Join-Path $repoRoot 'installer\webhook-server.iss'
+$dist = Join-Path $repoRoot 'dist'
+New-Item -ItemType Directory -Path $dist -Force | Out-Null
+
+Write-Host "Compiling installer with $iscc"
+& $iscc "/DAppVersion=$version" $iss
+if ($LASTEXITCODE -ne 0) { throw 'Inno Setup compile failed' }
+
+$out = Get-Item (Join-Path $dist "WebhookServer-Setup-$version.exe")
+Write-Host ""
+Write-Host ("Built: {0} ({1:n0} bytes)" -f $out.FullName, $out.Length) -ForegroundColor Green
diff --git a/scripts/examples/zerto-post-failover.ps1 b/scripts/examples/zerto-post-failover.ps1
new file mode 100644
index 0000000..ed55cb4
--- /dev/null
+++ b/scripts/examples/zerto-post-failover.ps1
@@ -0,0 +1,78 @@
+<#
+.SYNOPSIS
+ Zerto post-failover script. Fires the on-prem Webhook Server which does
+ the real work (DNS updates, service health checks, notifications).
+
+.DESCRIPTION
+ Designed to be dropped into a Zerto VPG's post-recovery script slot. The
+ Zerto Virtual Manager's PowerShell runner has a limited module set and
+ runs scripts synchronously, so this script:
+
+ - uses curl.exe (ships with Windows 10 1803+ / Server 2019+) instead
+ of any module-dependent HTTP client;
+ - calls an ASYNC webhook endpoint - the server returns 202 in
+ milliseconds and runs the actual work in the background;
+ - returns within seconds regardless of how long the post-failover
+ actions take, so Zerto's failover sequence is never blocked.
+
+ Wire this into your VPG via the Zerto UI:
+ VPG settings -> Recovery -> Scripts -> Post-Recovery Script
+ Path: C:\path\to\zerto-post-failover.ps1
+ Parameters: leave empty (we read from $env:ZertoVPGName)
+
+.NOTES
+ Configure $WebhookUrl and either:
+ - paste the bearer token directly into $Bearer (simplest, but the
+ token then lives in this file), or
+ - point $BearerFile at a file readable only by the ZVM service
+ account (better - same threat model as Zerto's own credential
+ storage).
+#>
+
+$ErrorActionPreference = 'Stop'
+
+# ----------------------------- CONFIGURE ---------------------------------
+$WebhookUrl = 'http://webhook.contoso.local:8080/hook/post-failover'
+$Bearer = '' # paste here, or use $BearerFile
+$BearerFile = 'C:\ProgramData\Zerto\webhook-token.txt' # one line: the token
+# -------------------------------------------------------------------------
+
+if (-not $Bearer -and (Test-Path $BearerFile)) {
+ $Bearer = (Get-Content -LiteralPath $BearerFile -TotalCount 1).Trim()
+}
+if (-not $Bearer) {
+ throw "No bearer token. Set `$Bearer in this script or write the token to $BearerFile."
+}
+
+# Compose the payload. Zerto exposes a few env vars; fall back gracefully.
+$payload = @{
+ operation = 'failover'
+ vpg = if ($env:ZertoVPGName) { $env:ZertoVPGName } else { 'unknown' }
+ timestamp = (Get-Date).ToUniversalTime().ToString('o')
+} | ConvertTo-Json -Compress
+
+# curl on Windows handles long / quoted JSON better via @file than via -d "...".
+$tempBody = Join-Path $env:TEMP ("zerto-webhook-{0}.json" -f ([guid]::NewGuid()))
+$payload | Out-File -FilePath $tempBody -Encoding utf8 -NoNewline
+
+try {
+ Write-Host "POST $WebhookUrl (vpg=$($env:ZertoVPGName))"
+ & curl.exe `
+ --silent --show-error --fail-with-body `
+ --max-time 10 `
+ -X POST `
+ -H "Authorization: Bearer $Bearer" `
+ -H "Content-Type: application/json" `
+ -d "@$tempBody" `
+ "$WebhookUrl"
+ if ($LASTEXITCODE -ne 0) {
+ # curl prints its own error to stderr; surface a non-zero exit so Zerto's
+ # script log records the failure but we don't block the failover.
+ Write-Warning "Webhook call failed with curl exit $LASTEXITCODE; continuing."
+ } else {
+ Write-Host "Webhook accepted (run id is in the response above)."
+ }
+}
+finally {
+ Remove-Item $tempBody -ErrorAction SilentlyContinue
+}
diff --git a/scripts/generate-icons.ps1 b/scripts/generate-icons.ps1
new file mode 100644
index 0000000..d68f977
--- /dev/null
+++ b/scripts/generate-icons.ps1
@@ -0,0 +1,138 @@
+<#
+.SYNOPSIS
+ Generates webhook-server.ico (multi-resolution) and webhook-server.png from
+ a programmatic design. Re-run after changing Draw-Icon to refresh assets.
+
+.DESCRIPTION
+ Renders the icon at 16/24/32/48/64/128/256 px using System.Drawing, then
+ assembles a Microsoft-format ICO file with each size embedded as PNG. No
+ external tools required.
+
+ Design: a rounded-square teal background (#0E7C66) with a stylized white
+ hook shape (a "J"-like curve with an arrow tip).
+#>
+[CmdletBinding()]
+param(
+ [string]$OutputDir = (Join-Path $PSScriptRoot '..\resources')
+)
+
+$ErrorActionPreference = 'Stop'
+Add-Type -AssemblyName System.Drawing
+
+New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null
+
+function New-IconBitmap([int]$size) {
+ $bmp = New-Object System.Drawing.Bitmap $size, $size, ([System.Drawing.Imaging.PixelFormat]::Format32bppArgb)
+ $g = [System.Drawing.Graphics]::FromImage($bmp)
+ $g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
+ $g.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
+ $g.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality
+
+ # Background: rounded square in brand teal.
+ $bgColor = [System.Drawing.Color]::FromArgb(0xFF, 0x0E, 0x7C, 0x66)
+ $bgBrush = New-Object System.Drawing.SolidBrush $bgColor
+ $radius = [int]($size * 0.22)
+ $rect = New-Object System.Drawing.RectangleF 0, 0, $size, $size
+
+ $path = New-Object System.Drawing.Drawing2D.GraphicsPath
+ $d = $radius * 2
+ $path.AddArc($rect.X, $rect.Y, $d, $d, 180, 90)
+ $path.AddArc($rect.Right - $d, $rect.Y, $d, $d, 270, 90)
+ $path.AddArc($rect.Right - $d, $rect.Bottom - $d, $d, $d, 0, 90)
+ $path.AddArc($rect.X, $rect.Bottom - $d, $d, $d, 90, 90)
+ $path.CloseFigure()
+ $g.FillPath($bgBrush, $path)
+
+ # Foreground: white hook shape - a thick curved stroke shaped like a "J"
+ # tipped with an arrowhead, sized relative to the canvas.
+ $fgColor = [System.Drawing.Color]::White
+ $stroke = [Math]::Max(2, [int]($size * 0.12))
+ $pen = New-Object System.Drawing.Pen $fgColor, $stroke
+ $pen.StartCap = [System.Drawing.Drawing2D.LineCap]::Round
+ $pen.EndCap = [System.Drawing.Drawing2D.LineCap]::Round
+ $pen.LineJoin = [System.Drawing.Drawing2D.LineJoin]::Round
+
+ # Hook curve: vertical down-stroke on the right, then a half-circle arc
+ # curling to the left and ending in a small filled dot for the hook tip.
+ $cx = [single]($size * 0.62)
+ $top = [single]($size * 0.22)
+ $bottom = [single]($size * 0.58)
+ $arcD = [single]($size * 0.34) # arc diameter
+ $arcLeft = [single]($cx - $arcD) # left edge of arc circle
+
+ # Vertical stroke from (cx, top) to (cx, bottom).
+ $g.DrawLine($pen, $cx, $top, $cx, $bottom)
+
+ # Half-circle arc beneath: starts at (cx, bottom), curls to (cx - arcD, bottom).
+ $arcRect = New-Object System.Drawing.RectangleF $arcLeft, ([single]($bottom - $arcD / 2)), $arcD, $arcD
+ $g.DrawArc($pen, $arcRect, 0, 180)
+
+ # Filled circle at the tip end of the arc.
+ $tipR = [single]($stroke * 0.6)
+ $tipX = $arcLeft
+ $tipY = [single]($bottom)
+ $brushFg = New-Object System.Drawing.SolidBrush $fgColor
+ $g.FillEllipse($brushFg, [single]($tipX - $tipR), [single]($tipY - $tipR), [single]($tipR * 2), [single]($tipR * 2))
+
+ $brushFg.Dispose(); $pen.Dispose(); $bgBrush.Dispose(); $path.Dispose()
+ $g.Dispose()
+ return $bmp
+}
+
+# Generate each size as PNG bytes. Hashtable keys are prefixed with "s" because
+# PowerShell hashtable lookups by integer key behave inconsistently with PSObject
+# wrapping; string keys round-trip cleanly.
+$sizes = @(16, 24, 32, 48, 64, 128, 256)
+$pngs = @{}
+foreach ($s in $sizes) {
+ $bmp = New-IconBitmap $s
+ $ms = New-Object System.IO.MemoryStream
+ $bmp.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)
+ $pngs["s$s"] = $ms.ToArray()
+ $ms.Dispose()
+ $bmp.Dispose()
+}
+
+# Save the master 256 PNG separately for places that need a transparent PNG.
+$pngPath = Join-Path $OutputDir 'webhook-server.png'
+[System.IO.File]::WriteAllBytes($pngPath, [byte[]]$pngs['s256'])
+
+# Assemble multi-resolution ICO.
+$icoPath = Join-Path $OutputDir 'webhook-server.ico'
+$ms = New-Object System.IO.MemoryStream
+$bw = New-Object System.IO.BinaryWriter $ms
+try {
+ $count = $sizes.Count
+ $bw.Write([UInt16]0) # idReserved
+ $bw.Write([UInt16]1) # idType: 1 = ICO
+ $bw.Write([UInt16]$count) # idCount
+
+ # Directory entries (16 bytes each).
+ $offset = 6 + 16 * $count
+ foreach ($s in $sizes) {
+ $bytes = $pngs["s$s"]
+ $w = if ($s -ge 256) { 0 } else { $s }
+ $h = if ($s -ge 256) { 0 } else { $s }
+ $bw.Write([byte]$w) # width
+ $bw.Write([byte]$h) # height
+ $bw.Write([byte]0) # colorCount
+ $bw.Write([byte]0) # reserved
+ $bw.Write([UInt16]1) # planes
+ $bw.Write([UInt16]32) # bitCount
+ $bw.Write([UInt32]$bytes.Length)
+ $bw.Write([UInt32]$offset)
+ $offset += $bytes.Length
+ }
+
+ # Image data.
+ foreach ($s in $sizes) { $bw.Write($pngs["s$s"]) }
+
+ $bw.Flush()
+ [System.IO.File]::WriteAllBytes($icoPath, $ms.ToArray())
+}
+finally {
+ $bw.Dispose(); $ms.Dispose()
+}
+
+Write-Host "Wrote $icoPath ($((Get-Item $icoPath).Length) bytes)"
+Write-Host "Wrote $pngPath ($((Get-Item $pngPath).Length) bytes)"
diff --git a/scripts/sync-wiki.ps1 b/scripts/sync-wiki.ps1
new file mode 100644
index 0000000..d62268b
--- /dev/null
+++ b/scripts/sync-wiki.ps1
@@ -0,0 +1,159 @@
+<#
+.SYNOPSIS
+ Mirrors the in-repo docs/ folder to a GitHub or Gitea wiki repo.
+
+.DESCRIPTION
+ Wikis are separate git repositories (e.g. .wiki.git) with a flat URL
+ structure. This script:
+
+ 1. Clones the wiki repo into a temp directory.
+ 2. Wipes its existing .md content.
+ 3. Copies each docs/*.md to a flattened wiki-style page name.
+ 4. Rewrites in-repo markdown links so they point at the wiki page slugs.
+ 5. Generates a _Sidebar.md so every wiki page has a navigation sidebar.
+ 6. Commits and pushes back if anything changed.
+
+ Idempotent. Safe to re-run.
+
+.PARAMETER WikiUrl
+ Full HTTPS URL to the wiki repo, including any embedded credentials. Examples:
+ https://github.com/recklessop/webhook-server.wiki.git
+ https://x-access-token:$TOKEN@github.com/recklessop/webhook-server.wiki.git
+ https://justin:$GITEA_TOKEN@git.jpaul.io/justin/webhook-server.wiki.git
+
+.PARAMETER AuthorName
+ git committer name. Defaults to "Webhook Server Wiki Sync".
+
+.PARAMETER AuthorEmail
+ git committer email. Defaults to "noreply@jpaul.me".
+
+.EXAMPLE
+ # Manual sync to Gitea (token in env)
+ $env:GITEA_TOKEN = '...'
+ ./scripts/sync-wiki.ps1 -WikiUrl "https://justin:$env:GITEA_TOKEN@git.jpaul.io/justin/webhook-server.wiki.git"
+
+.EXAMPLE
+ # Manual sync to GitHub (gh-issued token)
+ $token = & gh auth token
+ ./scripts/sync-wiki.ps1 -WikiUrl "https://x-access-token:$token@github.com/recklessop/webhook-server.wiki.git"
+#>
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory)][string]$WikiUrl,
+ [string]$AuthorName = 'Webhook Server Wiki Sync',
+ [string]$AuthorEmail = 'noreply@jpaul.me'
+)
+
+# Continue (not Stop) because git writes informational messages to stderr
+# (CRLF warnings, "remote: Processed N references" etc.) which PowerShell 5.1
+# escalates to a script-fatal error under Stop. We check $LASTEXITCODE
+# manually after each git call instead.
+$ErrorActionPreference = 'Continue'
+
+$repoRoot = Split-Path -Parent $PSScriptRoot
+$docsDir = Join-Path $repoRoot 'docs'
+$workDir = Join-Path $env:TEMP ("webhook-wiki-{0}" -f ([guid]::NewGuid().ToString('N').Substring(0, 8)))
+
+# Source path (relative to docs/) -> wiki page slug. Order matters for the sidebar.
+$mapping = [ordered]@{}
+$mapping.Add('README.md', 'Home')
+$mapping.Add('concepts.md', 'Concepts')
+$mapping.Add('installation.md', 'Installation')
+$mapping.Add('upgrading.md', 'Upgrading')
+$mapping.Add('uninstalling.md', 'Uninstalling')
+$mapping.Add('runas-modes.md', 'Run-As-Modes')
+$mapping.Add('service-account-and-ad.md', 'Service-Account-and-AD')
+$mapping.Add('network-and-security.md', 'Network-and-Security')
+$mapping.Add('troubleshooting.md', 'Troubleshooting')
+$mapping.Add('recipes/zerto-pre-post-scripts.md', 'Recipe-Zerto-Failover')
+$mapping.Add('recipes/github-style-hmac.md', 'Recipe-GitHub-HMAC')
+$mapping.Add('recipes/ui-on-desktop.md', 'Recipe-UI-on-Desktop')
+
+function Rewrite-Links([string]$content) {
+ foreach ($m in $mapping.GetEnumerator()) {
+ # Match (path/to/file.md) and (path/to/file.md#anchor) inside markdown
+ # link parens. The lookbehind ensures we're consuming a real link target.
+ $escaped = [regex]::Escape($m.Key)
+ $content = [regex]::Replace($content,
+ "\(\.?\.?/?$escaped(\#[^)\s]*)?\)",
+ "($($m.Value)`$1)")
+ }
+ # Also clean up doubled prefixes like "../../docs/" or "../" pointers that
+ # sometimes appear in cross-folder relative links from docs/recipes/.
+ return $content
+}
+
+function New-Sidebar() {
+ $lines = @()
+ $lines += "[Home](Home)"
+ $lines += ""
+ $lines += "## Topical"
+ foreach ($key in @('concepts.md','installation.md','upgrading.md','uninstalling.md','runas-modes.md','service-account-and-ad.md','network-and-security.md','troubleshooting.md')) {
+ $slug = $mapping[$key]
+ $lines += "- [$($slug -replace '-', ' ')]($slug)"
+ }
+ $lines += ""
+ $lines += "## Recipes"
+ foreach ($key in @('recipes/zerto-pre-post-scripts.md','recipes/github-style-hmac.md','recipes/ui-on-desktop.md')) {
+ $slug = $mapping[$key]
+ $lines += "- [$($slug -replace '^Recipe-' -replace '-', ' ')]($slug)"
+ }
+ return ($lines -join "`n")
+}
+
+# 1. Clone the wiki.
+Write-Host "Cloning wiki to $workDir..."
+& git clone --quiet $WikiUrl $workDir 2>&1 | Out-Null
+if ($LASTEXITCODE -ne 0) {
+ throw "git clone failed. Has the wiki been initialized? Visit the repo's Wiki tab and create the first page via the UI before running this script."
+}
+# Suppress git's CRLF nags for this throwaway clone so they don't become
+# "errors" via PowerShell's native-command stderr handling.
+& git -C $workDir config core.autocrlf false 2>&1 | Out-Null
+& git -C $workDir config core.safecrlf false 2>&1 | Out-Null
+
+try {
+ Push-Location $workDir
+ try {
+ # 2. Wipe existing markdown so removed source files vanish from the wiki.
+ Get-ChildItem -Filter "*.md" -Force | Remove-Item -Force
+
+ # 3. Copy + transform each source file.
+ $written = 0
+ foreach ($entry in $mapping.GetEnumerator()) {
+ $src = Join-Path $docsDir $entry.Key
+ $dst = Join-Path $workDir "$($entry.Value).md"
+ if (-not (Test-Path $src)) {
+ Write-Warning "Source missing, skipping: $src"
+ continue
+ }
+ $content = Get-Content -LiteralPath $src -Raw
+ $content = Rewrite-Links $content
+ Set-Content -LiteralPath $dst -Value $content -Encoding utf8 -NoNewline
+ $written++
+ }
+ Write-Host "Wrote $written markdown pages."
+
+ # 4. Sidebar
+ Set-Content -LiteralPath (Join-Path $workDir '_Sidebar.md') -Value (New-Sidebar) -Encoding utf8 -NoNewline
+
+ # 5. Commit + push if anything actually changed. Drain stderr from each
+ # git invocation so PowerShell doesn't treat warnings as errors.
+ & git add -A 2>&1 | Out-Null
+ $changes = & git status --porcelain 2>&1
+ if (-not $changes) {
+ Write-Host "Wiki already up to date."
+ return
+ }
+ $sha = & git -C $repoRoot rev-parse --short HEAD 2>&1
+ & git -c "user.name=$AuthorName" -c "user.email=$AuthorEmail" commit -q -m "Sync from docs/ at $sha" 2>&1 | Out-Null
+ if ($LASTEXITCODE -ne 0) { throw "git commit failed (exit $LASTEXITCODE)" }
+ & git push --quiet 2>&1 | Out-Null
+ if ($LASTEXITCODE -ne 0) { throw "git push failed (exit $LASTEXITCODE)" }
+ Write-Host "Pushed updated wiki."
+ }
+ finally { Pop-Location }
+}
+finally {
+ Remove-Item -Recurse -Force $workDir -ErrorAction SilentlyContinue
+}
diff --git a/src/WebhookServer.Core/Ipc/AdminProtocol.cs b/src/WebhookServer.Core/Ipc/AdminProtocol.cs
index 2c9b37f..449eb58 100644
--- a/src/WebhookServer.Core/Ipc/AdminProtocol.cs
+++ b/src/WebhookServer.Core/Ipc/AdminProtocol.cs
@@ -23,6 +23,28 @@ public static class AdminOps
public const string BindHttps = "bind-https";
public const string RestartListener = "restart-listener";
public const string Ping = "ping";
+ 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
+{
+ public string FileName { get; set; } = "";
+ public DateTimeOffset SavedAt { get; set; }
+ public long SizeBytes { get; set; }
+ public string? Description { get; set; }
+}
+
+public sealed class RestoreBackupArgs
+{
+ public string FileName { get; set; } = "";
+}
+
+public sealed class CreateCheckpointArgs
+{
+ public string? Description { get; set; }
}
public sealed class AdminRequest
diff --git a/src/WebhookServer.Core/Storage/ConfigStore.cs b/src/WebhookServer.Core/Storage/ConfigStore.cs
index 7e8b9a4..0d1c61b 100644
--- a/src/WebhookServer.Core/Storage/ConfigStore.cs
+++ b/src/WebhookServer.Core/Storage/ConfigStore.cs
@@ -38,6 +38,32 @@ public sealed class ConfigStore
var dir = System.IO.Path.GetDirectoryName(Path);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
+ // Snapshot the previous config (if any) into the backups folder before
+ // overwriting. Cheap insurance against typos in the GUI.
+ if (File.Exists(Path) && !string.IsNullOrEmpty(dir))
+ {
+ try
+ {
+ var backupsDir = System.IO.Path.Combine(dir, "backups");
+ Directory.CreateDirectory(backupsDir);
+ var stamp = DateTime.UtcNow.ToString("yyyyMMdd-HHmmss");
+ var backupPath = System.IO.Path.Combine(backupsDir, $"config-{stamp}.json");
+ if (!File.Exists(backupPath))
+ {
+ File.Copy(Path, backupPath, overwrite: false);
+ var sidecar = new { description = "Before save", reason = "before-save" };
+ File.WriteAllText(
+ System.IO.Path.ChangeExtension(backupPath, ".meta.json"),
+ JsonSerializer.Serialize(sidecar, ConfigJson.Compact));
+ }
+ PruneBackups(backupsDir, retain: 90);
+ }
+ catch
+ {
+ // Backup is best-effort; don't fail the save if it can't write.
+ }
+ }
+
var tmp = Path + ".tmp";
await using (var fs = File.Create(tmp))
{
@@ -49,6 +75,24 @@ public sealed class ConfigStore
File.Move(tmp, Path, overwrite: true);
}
+ private static void PruneBackups(string backupsDir, int retain)
+ {
+ var stale = new DirectoryInfo(backupsDir).GetFiles("config-*.json")
+ .Where(f => !f.Name.EndsWith(".meta.json", StringComparison.OrdinalIgnoreCase))
+ .OrderByDescending(f => f.Name)
+ .Skip(retain);
+ foreach (var f in stale)
+ {
+ try
+ {
+ f.Delete();
+ var sidecar = System.IO.Path.ChangeExtension(f.FullName, ".meta.json");
+ if (File.Exists(sidecar)) File.Delete(sidecar);
+ }
+ catch { }
+ }
+ }
+
public static void ClearPlaintexts(ServerConfig config)
{
foreach (var ep in config.Endpoints)
diff --git a/src/WebhookServer.Gui/App.xaml.cs b/src/WebhookServer.Gui/App.xaml.cs
index 9e28109..845e1e0 100644
--- a/src/WebhookServer.Gui/App.xaml.cs
+++ b/src/WebhookServer.Gui/App.xaml.cs
@@ -1,13 +1,5 @@
-using System.Configuration;
-using System.Data;
-using System.Windows;
-
namespace WebhookServer.Gui;
-///
-/// Interaction logic for App.xaml
-///
public partial class App : Application
{
}
-
diff --git a/src/WebhookServer.Gui/Converters/Converters.cs b/src/WebhookServer.Gui/Converters/Converters.cs
index e09d260..e346830 100644
--- a/src/WebhookServer.Gui/Converters/Converters.cs
+++ b/src/WebhookServer.Gui/Converters/Converters.cs
@@ -1,6 +1,7 @@
using System.Globalization;
using System.Windows.Data;
-using System.Windows.Media;
+using Brush = System.Windows.Media.Brush;
+using Brushes = System.Windows.Media.Brushes;
namespace WebhookServer.Gui.Converters;
diff --git a/src/WebhookServer.Gui/GlobalUsings.cs b/src/WebhookServer.Gui/GlobalUsings.cs
new file mode 100644
index 0000000..59cef05
--- /dev/null
+++ b/src/WebhookServer.Gui/GlobalUsings.cs
@@ -0,0 +1,15 @@
+// Enabling UseWindowsForms (for the system tray NotifyIcon) brings the WinForms
+// namespace into scope, which conflicts with WPF for several common type names.
+// Alias the most-used types to their WPF variants project-wide so existing code
+// keeps compiling. Files that genuinely need a WinForms type import it explicitly
+// (System.Windows.Forms.NotifyIcon etc. in Services/TrayIcon.cs).
+
+global using Application = System.Windows.Application;
+global using MessageBox = System.Windows.MessageBox;
+global using Clipboard = System.Windows.Clipboard;
+global using TextBox = System.Windows.Controls.TextBox;
+global using RadioButton = System.Windows.Controls.RadioButton;
+global using MessageBoxButton = System.Windows.MessageBoxButton;
+global using MessageBoxImage = System.Windows.MessageBoxImage;
+global using MessageBoxResult = System.Windows.MessageBoxResult;
+global using Binding = System.Windows.Data.Binding;
diff --git a/src/WebhookServer.Gui/MainWindow.xaml b/src/WebhookServer.Gui/MainWindow.xaml
index 17ea76f..466c38a 100644
--- a/src/WebhookServer.Gui/MainWindow.xaml
+++ b/src/WebhookServer.Gui/MainWindow.xaml
@@ -7,6 +7,7 @@
xmlns:models="clr-namespace:WebhookServer.Core.Models;assembly=WebhookServer.Core"
mc:Ignorable="d"
Title="Webhook Server" Height="600" Width="1000"
+ Icon="/webhook-server.ico"
d:DataContext="{d:DesignInstance Type=vm:MainViewModel}">
@@ -26,9 +27,9 @@
@@ -38,6 +39,8 @@
+
+
diff --git a/src/WebhookServer.Gui/MainWindow.xaml.cs b/src/WebhookServer.Gui/MainWindow.xaml.cs
index 3df0142..9f387ff 100644
--- a/src/WebhookServer.Gui/MainWindow.xaml.cs
+++ b/src/WebhookServer.Gui/MainWindow.xaml.cs
@@ -8,12 +8,37 @@ namespace WebhookServer.Gui;
public partial class MainWindow : Window
{
+ private readonly TrayIcon _tray;
+ private readonly MainViewModel _vm;
+
public MainWindow()
{
InitializeComponent();
- var vm = new MainViewModel(new AdminPipeClient());
- DataContext = vm;
- Loaded += async (_, _) => await vm.RefreshCommand.ExecuteAsync(null);
+ _vm = new MainViewModel(new AdminPipeClient());
+ DataContext = _vm;
+
+ _tray = new TrayIcon(
+ resolveMainWindow: () => Application.Current.MainWindow,
+ restartServiceAsync: async () => await new AdminPipeClient().RestartListenerAsync());
+
+ Loaded += async (_, _) => await _vm.RefreshCommand.ExecuteAsync(null);
+ StateChanged += OnStateChanged;
+ Closed += (_, _) => _tray.Dispose();
+ }
+
+ private void OnStateChanged(object? sender, EventArgs e)
+ {
+ // Minimize-to-tray: hide the window when the user minimizes; restoring is
+ // via the tray icon's double-click or context menu.
+ if (WindowState == WindowState.Minimized)
+ {
+ Hide();
+ ShowInTaskbar = false;
+ }
+ else
+ {
+ ShowInTaskbar = true;
+ }
}
private void OnLogTailChanged(object sender, TextChangedEventArgs e)
@@ -27,4 +52,5 @@ public partial class MainWindow : Window
if (DataContext is MainViewModel vm && vm.EditEndpointCommand.CanExecute(null))
vm.EditEndpointCommand.Execute(null);
}
+
}
diff --git a/src/WebhookServer.Gui/Services/AdminPipeClient.cs b/src/WebhookServer.Gui/Services/AdminPipeClient.cs
index cc56d04..3611aaa 100644
--- a/src/WebhookServer.Gui/Services/AdminPipeClient.cs
+++ b/src/WebhookServer.Gui/Services/AdminPipeClient.cs
@@ -86,4 +86,21 @@ public sealed class AdminPipeClient
var lst = resp.Data.Value.GetProperty("lines").Deserialize>(AdminProtocol.JsonOptions);
return lst ?? new List();
}
+
+ public async Task> ListBackupsAsync(CancellationToken ct = default)
+ {
+ var resp = await InvokeAsync(AdminOps.ListBackups, null, ct).ConfigureAwait(false);
+ if (!resp.Ok || resp.Data is null) return new List();
+ var lst = resp.Data.Value.GetProperty("backups").Deserialize>(AdminProtocol.JsonOptions);
+ return lst ?? new List();
+ }
+
+ public Task RestoreBackupAsync(string fileName, CancellationToken ct = default) =>
+ InvokeAsync(AdminOps.RestoreBackup, new RestoreBackupArgs { FileName = fileName }, ct);
+
+ public Task ImportConfigAsync(ServerConfig config, CancellationToken ct = default) =>
+ InvokeAsync(AdminOps.ImportConfig, config, ct);
+
+ public Task CreateCheckpointAsync(string? description, CancellationToken ct = default) =>
+ InvokeAsync(AdminOps.CreateCheckpoint, new CreateCheckpointArgs { Description = description }, ct);
}
diff --git a/src/WebhookServer.Gui/Services/TrayIcon.cs b/src/WebhookServer.Gui/Services/TrayIcon.cs
new file mode 100644
index 0000000..7cedfa5
--- /dev/null
+++ b/src/WebhookServer.Gui/Services/TrayIcon.cs
@@ -0,0 +1,86 @@
+using System.Drawing;
+using System.Runtime.Versioning;
+using System.Windows;
+using System.Windows.Forms;
+
+namespace WebhookServer.Gui.Services;
+
+///
+/// Minimal system tray icon using Windows Forms NotifyIcon. Owns a context menu
+/// (Open / Restart service / Exit) and toggles the main window visibility on
+/// double-click. Hide-to-tray on minimize is wired in MainWindow.xaml.cs.
+///
+[SupportedOSPlatform("windows")]
+public sealed class TrayIcon : IDisposable
+{
+ private readonly NotifyIcon _icon;
+ private readonly Func _resolveMainWindow;
+ private readonly Func _restartServiceAsync;
+
+ public TrayIcon(Func resolveMainWindow, Func restartServiceAsync)
+ {
+ _resolveMainWindow = resolveMainWindow;
+ _restartServiceAsync = restartServiceAsync;
+
+ _icon = new NotifyIcon
+ {
+ Icon = LoadEmbeddedIcon(),
+ Text = "Webhook Server",
+ Visible = true,
+ };
+ _icon.DoubleClick += (_, _) => ShowMainWindow();
+ _icon.ContextMenuStrip = BuildMenu();
+ }
+
+ private ContextMenuStrip BuildMenu()
+ {
+ var menu = new ContextMenuStrip();
+ menu.Items.Add("&Open Webhook Server", null, (_, _) => ShowMainWindow());
+ menu.Items.Add(new ToolStripSeparator());
+ menu.Items.Add("&Restart service", null, async (_, _) => await _restartServiceAsync().ConfigureAwait(false));
+ menu.Items.Add(new ToolStripSeparator());
+ menu.Items.Add("E&xit", null, (_, _) => Application.Current.Shutdown());
+ return menu;
+ }
+
+ private void ShowMainWindow()
+ {
+ var w = _resolveMainWindow();
+ if (w is null) return;
+ if (w.WindowState == WindowState.Minimized) w.WindowState = WindowState.Normal;
+ w.Show();
+ w.Activate();
+ w.Topmost = true;
+ w.Topmost = false;
+ }
+
+ private static Icon LoadEmbeddedIcon()
+ {
+ // Pulled from the WPF Resource items in the csproj via the application
+ // pack URI. Falling back to SystemIcons keeps the tray usable if the
+ // resource is somehow missing.
+ try
+ {
+ var uri = new Uri("pack://application:,,,/webhook-server.ico", UriKind.Absolute);
+ using var stream = Application.GetResourceStream(uri).Stream;
+ return new Icon(stream);
+ }
+ catch
+ {
+ return SystemIcons.Application;
+ }
+ }
+
+ public void ShowBalloon(string title, string message)
+ {
+ _icon.BalloonTipTitle = title;
+ _icon.BalloonTipText = message;
+ _icon.ShowBalloonTip(3000);
+ }
+
+ public void Dispose()
+ {
+ _icon.Visible = false;
+ _icon.Dispose();
+ }
+}
diff --git a/src/WebhookServer.Gui/ViewModels/ConfigCheckpointsViewModel.cs b/src/WebhookServer.Gui/ViewModels/ConfigCheckpointsViewModel.cs
new file mode 100644
index 0000000..d0fa3fc
--- /dev/null
+++ b/src/WebhookServer.Gui/ViewModels/ConfigCheckpointsViewModel.cs
@@ -0,0 +1,105 @@
+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()
+ {
+ // Prompt for an optional description on the UI thread.
+ string? description = null;
+ var prompted = Application.Current.Dispatcher.Invoke(() =>
+ {
+ var dlg = new Views.TakeCheckpointDialog { Owner = Application.Current.MainWindow };
+ if (dlg.ShowDialog() != true) return false;
+ description = string.IsNullOrWhiteSpace(dlg.Description) ? null : dlg.Description;
+ return true;
+ });
+ if (!prompted) return;
+
+ try
+ {
+ var entry = await _client.CreateCheckpointAsync(description).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 297fd91..5e944b6 100644
--- a/src/WebhookServer.Gui/ViewModels/MainViewModel.cs
+++ b/src/WebhookServer.Gui/ViewModels/MainViewModel.cs
@@ -175,6 +175,71 @@ public sealed partial class MainViewModel : ObservableObject
}
}
+ [RelayCommand]
+ private void ShowConfigCheckpoints()
+ {
+ var dlg = new Views.ConfigCheckpointsDialog
+ {
+ 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]
+ private async Task ExportConfigAsync()
+ {
+ try
+ {
+ var snap = await _client.GetConfigAsync().ConfigureAwait(false);
+ if (snap is null) { ShowError("Export failed", new InvalidOperationException("Service did not return a config.")); return; }
+
+ var dlg = new Microsoft.Win32.SaveFileDialog
+ {
+ FileName = $"webhook-server-config-{DateTime.Now:yyyyMMdd-HHmmss}.json",
+ DefaultExt = ".json",
+ Filter = "JSON config (*.json)|*.json",
+ };
+ if (dlg.ShowDialog() != true) return;
+
+ var json = System.Text.Json.JsonSerializer.Serialize(snap, WebhookServer.Core.Storage.ConfigJson.Pretty);
+ await System.IO.File.WriteAllTextAsync(dlg.FileName, json).ConfigureAwait(false);
+ }
+ catch (Exception ex) { ShowError("Export failed", ex); }
+ }
+
+ [RelayCommand]
+ private async Task ImportConfigAsync()
+ {
+ var dlg = new Microsoft.Win32.OpenFileDialog
+ {
+ Filter = "JSON config (*.json)|*.json",
+ CheckFileExists = true,
+ };
+ if (dlg.ShowDialog() != true) return;
+
+ try
+ {
+ var json = await System.IO.File.ReadAllTextAsync(dlg.FileName).ConfigureAwait(false);
+ var cfg = System.Text.Json.JsonSerializer.Deserialize(json, WebhookServer.Core.Storage.ConfigJson.Pretty);
+ if (cfg is null) throw new InvalidOperationException("File did not contain a valid config.");
+
+ var ok = MessageBox.Show(
+ $"Replace the current configuration with {dlg.FileName}?\n\nA checkpoint of the current config is saved first, so you can roll back from File → Config Checkpoints.",
+ "Import config",
+ MessageBoxButton.OKCancel,
+ MessageBoxImage.Warning);
+ if (ok != MessageBoxResult.OK) return;
+
+ await _client.ImportConfigAsync(cfg).ConfigureAwait(false);
+ await RefreshAsync().ConfigureAwait(false);
+ }
+ catch (Exception ex) { ShowError("Import failed", ex); }
+ }
+
[RelayCommand]
private async Task RestartServiceAsync()
{
@@ -204,6 +269,23 @@ public sealed partial class MainViewModel : ObservableObject
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()
{
diff --git a/src/WebhookServer.Gui/Views/AboutDialog.xaml b/src/WebhookServer.Gui/Views/AboutDialog.xaml
index 8612b27..455f611 100644
--- a/src/WebhookServer.Gui/Views/AboutDialog.xaml
+++ b/src/WebhookServer.Gui/Views/AboutDialog.xaml
@@ -2,9 +2,10 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="About Webhook Server"
- Height="320" Width="420"
+ Height="360" Width="440"
ResizeMode="NoResize"
WindowStartupLocation="CenterOwner"
+ Icon="/webhook-server.ico"
ShowInTaskbar="False">
diff --git a/src/WebhookServer.Gui/Views/ConfigCheckpointsDialog.xaml b/src/WebhookServer.Gui/Views/ConfigCheckpointsDialog.xaml
new file mode 100644
index 0000000..45ce611
--- /dev/null
+++ b/src/WebhookServer.Gui/Views/ConfigCheckpointsDialog.xaml
@@ -0,0 +1,53 @@
+
+
+
+ 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.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/WebhookServer.Gui/Views/ConfigCheckpointsDialog.xaml.cs b/src/WebhookServer.Gui/Views/ConfigCheckpointsDialog.xaml.cs
new file mode 100644
index 0000000..909105e
--- /dev/null
+++ b/src/WebhookServer.Gui/Views/ConfigCheckpointsDialog.xaml.cs
@@ -0,0 +1,19 @@
+using System.Windows;
+using WebhookServer.Gui.ViewModels;
+
+namespace WebhookServer.Gui.Views;
+
+public partial class ConfigCheckpointsDialog : Window
+{
+ public ConfigCheckpointsDialog()
+ {
+ InitializeComponent();
+ Loaded += async (_, _) =>
+ {
+ if (DataContext is ConfigCheckpointsViewModel vm)
+ await vm.RefreshAsync();
+ };
+ }
+
+ private void OnClose(object sender, RoutedEventArgs e) => Close();
+}
diff --git a/src/WebhookServer.Gui/Views/TakeCheckpointDialog.xaml b/src/WebhookServer.Gui/Views/TakeCheckpointDialog.xaml
new file mode 100644
index 0000000..0591b88
--- /dev/null
+++ b/src/WebhookServer.Gui/Views/TakeCheckpointDialog.xaml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/WebhookServer.Gui/Views/TakeCheckpointDialog.xaml.cs b/src/WebhookServer.Gui/Views/TakeCheckpointDialog.xaml.cs
new file mode 100644
index 0000000..aa43fd7
--- /dev/null
+++ b/src/WebhookServer.Gui/Views/TakeCheckpointDialog.xaml.cs
@@ -0,0 +1,27 @@
+using System.Windows;
+
+namespace WebhookServer.Gui.Views;
+
+public partial class TakeCheckpointDialog : Window
+{
+ public string Description { get; private set; } = "";
+
+ public TakeCheckpointDialog()
+ {
+ InitializeComponent();
+ Loaded += (_, _) => DescriptionBox.Focus();
+ }
+
+ private void OnOk(object sender, RoutedEventArgs e)
+ {
+ Description = DescriptionBox.Text?.Trim() ?? "";
+ DialogResult = true;
+ Close();
+ }
+
+ private void OnCancel(object sender, RoutedEventArgs e)
+ {
+ DialogResult = false;
+ Close();
+ }
+}
diff --git a/src/WebhookServer.Gui/WebhookServer.Gui.csproj b/src/WebhookServer.Gui/WebhookServer.Gui.csproj
index 4ffed94..c0415de 100644
--- a/src/WebhookServer.Gui/WebhookServer.Gui.csproj
+++ b/src/WebhookServer.Gui/WebhookServer.Gui.csproj
@@ -14,6 +14,15 @@
enableenabletrue
+ true
+ ..\..\resources\webhook-server.ico
+ app.manifest
+ Webhook Server
+
+
+
+
+
diff --git a/src/WebhookServer.Gui/app.manifest b/src/WebhookServer.Gui/app.manifest
new file mode 100644
index 0000000..fe654a1
--- /dev/null
+++ b/src/WebhookServer.Gui/app.manifest
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/WebhookServer.Service/AdminPipeServer.cs b/src/WebhookServer.Service/AdminPipeServer.cs
index 54fff23..37f27b8 100644
--- a/src/WebhookServer.Service/AdminPipeServer.cs
+++ b/src/WebhookServer.Service/AdminPipeServer.cs
@@ -202,11 +202,128 @@ internal sealed class AdminPipeServer : BackgroundService
return AdminResponse.Success(new { lines });
}
+ case AdminOps.ListBackups:
+ {
+ var entries = ListBackups();
+ return AdminResponse.Success(new { backups = entries });
+ }
+
+ case AdminOps.RestoreBackup:
+ {
+ var args = DeserializeData(request) ?? throw new ArgumentException("missing fileName");
+ var restored = await RestoreBackupAsync(args.FileName, ct).ConfigureAwait(false);
+ _logger.LogInformation("Restored config from backup {File}", args.FileName);
+ return AdminResponse.Success(SafeSnapshotForWire(restored));
+ }
+
+ case AdminOps.ImportConfig:
+ {
+ var incoming = DeserializeData(request) ?? throw new ArgumentException("missing config payload");
+ MergeWithExistingSecrets(incoming, _state.Snapshot());
+ await _state.ReplaceAsync(incoming, ct).ConfigureAwait(false);
+ _logger.LogInformation("Config imported ({Count} endpoints)", incoming.Endpoints.Count);
+ return AdminResponse.Success(SafeSnapshotForWire(_state.Snapshot()));
+ }
+
+ case AdminOps.CreateCheckpoint:
+ {
+ var args = DeserializeData(request);
+ var description = args?.Description;
+ if (string.IsNullOrWhiteSpace(description)) description = "Manual checkpoint";
+ var entry = CreateCheckpoint("manual", description);
+ _logger.LogInformation("Manual checkpoint created: {File} ({Desc})", entry.FileName, description);
+ return AdminResponse.Success(entry);
+ }
+
default:
return AdminResponse.Failure($"unknown op '{request.Op}'");
}
}
+ ///
+ /// Snapshot the current config.json into the backups folder. Used by the
+ /// "Take checkpoint now" GUI action, the midnight scheduler, and the
+ /// auto-on-save hook in ConfigStore. Description is stored in a sidecar
+ /// .meta.json file next to the snapshot so it survives restarts and can
+ /// be rendered in the GUI.
+ ///
+ public static BackupEntry CreateCheckpoint(string reason, string description)
+ {
+ var configPath = ServicePaths.ConfigPath;
+ if (!File.Exists(configPath))
+ throw new FileNotFoundException("no config.json exists yet to snapshot");
+
+ var dir = Path.Combine(ServicePaths.DataRoot, "backups");
+ Directory.CreateDirectory(dir);
+
+ var stamp = DateTime.UtcNow.ToString("yyyyMMdd-HHmmss");
+ var dest = Path.Combine(dir, $"config-{stamp}.json");
+ if (File.Exists(dest))
+ dest = Path.Combine(dir, $"config-{stamp}-{reason}.json");
+
+ File.Copy(configPath, dest);
+
+ // Write the sidecar metadata.
+ var sidecarPath = Path.ChangeExtension(dest, ".meta.json");
+ var sidecar = new { description, reason };
+ File.WriteAllText(sidecarPath, JsonSerializer.Serialize(sidecar, ConfigJson.Compact));
+
+ var info = new FileInfo(dest);
+ return new BackupEntry
+ {
+ FileName = info.Name,
+ SavedAt = info.LastWriteTimeUtc,
+ SizeBytes = info.Length,
+ Description = description,
+ };
+ }
+
+ private static List ListBackups()
+ {
+ var dir = Path.Combine(ServicePaths.DataRoot, "backups");
+ if (!Directory.Exists(dir)) return new List();
+ return new DirectoryInfo(dir).GetFiles("config-*.json")
+ .Where(f => !f.Name.EndsWith(".meta.json", StringComparison.OrdinalIgnoreCase))
+ .OrderByDescending(f => f.Name)
+ .Take(50)
+ .Select(f => new BackupEntry
+ {
+ FileName = f.Name,
+ SavedAt = f.LastWriteTimeUtc,
+ SizeBytes = f.Length,
+ Description = ReadSidecarDescription(f.FullName),
+ })
+ .ToList();
+ }
+
+ private static string? ReadSidecarDescription(string snapshotPath)
+ {
+ try
+ {
+ var sidecarPath = Path.ChangeExtension(snapshotPath, ".meta.json");
+ if (!File.Exists(sidecarPath)) return null;
+ using var doc = JsonDocument.Parse(File.ReadAllText(sidecarPath));
+ return doc.RootElement.TryGetProperty("description", out var d) ? d.GetString() : null;
+ }
+ catch { return null; }
+ }
+
+ private async Task RestoreBackupAsync(string fileName, CancellationToken ct)
+ {
+ // Refuse anything that tries to escape the backups directory.
+ if (fileName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0)
+ throw new ArgumentException("invalid file name");
+ var backupPath = Path.Combine(ServicePaths.DataRoot, "backups", fileName);
+ if (!File.Exists(backupPath))
+ throw new FileNotFoundException("backup not found", fileName);
+
+ await using var fs = File.OpenRead(backupPath);
+ var cfg = await JsonSerializer.DeserializeAsync(fs, ConfigJson.Pretty, ct).ConfigureAwait(false)
+ ?? throw new InvalidOperationException("backup file was empty");
+ await _state.ReplaceAsync(cfg, ct).ConfigureAwait(false);
+ return _state.Snapshot();
+ }
+
private ServerConfig CloneSnapshotForEdit()
{
// Round-trip via JSON to avoid sharing references with the live snapshot.
diff --git a/src/WebhookServer.Service/CheckpointScheduler.cs b/src/WebhookServer.Service/CheckpointScheduler.cs
new file mode 100644
index 0000000..cf9faa9
--- /dev/null
+++ b/src/WebhookServer.Service/CheckpointScheduler.cs
@@ -0,0 +1,50 @@
+using System.Runtime.Versioning;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+
+namespace WebhookServer.Service;
+
+///
+/// Creates a daily config checkpoint at midnight (local time). Combined with
+/// the auto-on-save snapshots in ConfigStore.SaveAsync, this guarantees a
+/// rollback point for every day even if the user makes no changes.
+///
+[SupportedOSPlatform("windows")]
+internal sealed class CheckpointScheduler : BackgroundService
+{
+ private readonly ILogger _logger;
+
+ public CheckpointScheduler(ILogger logger)
+ {
+ _logger = logger;
+ }
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ _logger.LogInformation("Daily checkpoint scheduler running");
+
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ var now = DateTime.Now;
+ var nextMidnight = now.Date.AddDays(1);
+ var delay = nextMidnight - now;
+
+ try { await Task.Delay(delay, stoppingToken).ConfigureAwait(false); }
+ catch (OperationCanceledException) { return; }
+
+ try
+ {
+ var entry = AdminPipeServer.CreateCheckpoint("daily", "Nightly auto-checkpoint");
+ _logger.LogInformation("Daily checkpoint created: {File}", entry.FileName);
+ }
+ catch (FileNotFoundException)
+ {
+ // No config.json yet (fresh install, GUI never opened) - skip silently.
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Daily checkpoint creation failed");
+ }
+ }
+ }
+}
diff --git a/src/WebhookServer.Service/Program.cs b/src/WebhookServer.Service/Program.cs
index 8eefe98..fbe1766 100644
--- a/src/WebhookServer.Service/Program.cs
+++ b/src/WebhookServer.Service/Program.cs
@@ -49,6 +49,7 @@ try
builder.Services.AddSingleton();
builder.Services.AddHostedService();
builder.Services.AddHostedService();
+ builder.Services.AddHostedService();
var app = builder.Build();