Bring main up to date: v0.1.2 + wiki sync #2

Closed
justin wants to merge 4 commits from claude/pensive-easley-4abcbe into main
45 changed files with 2802 additions and 104 deletions
+27
View File
@@ -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
+71
View File
@@ -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
+27
View File
@@ -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
+1 -1
View File
@@ -1,7 +1,7 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<Version>0.1.0</Version> <Version>0.1.2</Version>
<Authors>Justin Paul</Authors> <Authors>Justin Paul</Authors>
<Company>Justin Paul</Company> <Company>Justin Paul</Company>
<Product>Webhook Server</Product> <Product>Webhook Server</Product>
+65 -85
View File
@@ -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: <https://github.com/recklessop/webhook-server/releases/latest>
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 ## Highlights
- **Many endpoints, one service.** Each webhook is a configured URL slug mapped to a script or command. - **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 auth** HMAC signature (GitHub / Stripe / Slack style), bearer token, or none.
- **Per-endpoint IP allowlist.** Restrict by IP or CIDR (IPv4 + IPv6). Empty list = open. Checked before auth. - **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 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. - **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; async returns 202 immediately. - **Sync or async per endpoint.** Sync returns exit code + stdout / stderr to the caller; 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). - **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.
- **Service-first.** Always-on Windows Service. The WPF GUI is a thin config/monitor client over a named pipe. - **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; HTTP works out of the box. - **HTTPS optional.** Bind a `.pfx` or cert-store thumbprint from the GUI.
- **Secrets at rest.** Tokens and HMAC secrets are encrypted via DPAPI (LocalMachine scope) in `config.json`. - **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 ## Architecture
``` ```
+------------------+ named pipe +------------------------------+ +------------------+ named pipe +-------------------------------+
| WPF GUI app | <----------> | Windows Service | | GUI (WPF) | <-------------> | Windows Service |
| (config/monitor)| | - Kestrel: webhook listener | | add / edit / | SYSTEM+admin | - Kestrel: hook listener |
+------------------+ | - Named-pipe admin server | | view logs | ACL'd | - Admin pipe server |
| - Executor pool | +------------------+ | - Executor (process runner) |
| - Callback dispatcher |
| - Serilog file logging | | - Serilog file logging |
+------------------------------+ +-------------------------------+
^ |
C:\ProgramData\WebhookServer\ C:\ProgramData\WebhookServer\
- config.json (DPAPI-encrypted secrets) - config.json (DPAPI-encrypted)
- logs\*.log - backups\ (auto-snapshots)
- logs\ (daily rolling)
``` ```
## Project layout (planned) ## Documentation
``` Everything you need to operate the server:
WebhookServer.sln
src/ - [Concepts](docs/concepts.md) — what a webhook is and how this server uses one
WebhookServer.Core/ class lib: models, auth, execution, storage, IPC - [Installation](docs/installation.md) — interactive and silent install
WebhookServer.Service/ .NET 8 Worker Service (hosts Kestrel + admin pipe) - [Upgrading](docs/upgrading.md) — single click; what's preserved
WebhookServer.Gui/ WPF (.NET 8) MVVM config/monitor client - [Uninstalling](docs/uninstalling.md) — clean removal
scripts/ - [Run As modes](docs/runas-modes.md) — Service / InteractiveUser / SpecificUser
install-service.ps1 - [Service account & Active Directory](docs/service-account-and-ad.md) — gMSA + delegated rights
uninstall-service.ps1 - [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 ## Requirements
- Windows 10 / 11 or Windows Server 2019+ - Windows 10 / 11 / Server 2019+
- .NET 8 SDK to build, .NET 8 Runtime (or self-contained publish) to run - x64
- Administrator rights to install the service and to run the GUI (the admin named pipe is ACL'd to SYSTEM + Administrators) - .NET 8 SDK to build (the released installer includes everything else)
## Building (on Windows) ## Building from source
```powershell ```powershell
dotnet restore git clone https://github.com/recklessop/webhook-server.git
dotnet build -c Release cd webhook-server
dotnet publish src/WebhookServer.Service -c Release -r win-x64 --self-contained
dotnet publish src/WebhookServer.Gui -c Release -r win-x64 --self-contained # 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 ## License
Not yet chosen. TBD.
+32
View File
@@ -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)
+77
View File
@@ -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).
+108
View File
@@ -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.
+131
View File
@@ -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/<slug>` — 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 <secret>` |
| **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=<hex digest>` 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 (13 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
+122
View File
@@ -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=<hex>` 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=<timestamp>,v1=<sig>,v0=<sig>`, where `v1` is HMAC-SHA256 of `<timestamp>.<body>`. 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.
+68
View File
@@ -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.
+243
View File
@@ -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.
+78
View File
@@ -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
```
+149
View File
@@ -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).
+136
View File
@@ -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 <id> <slug> ok exit=0 dur=<ms>ms stdout=...` on success
- `[WRN] Run <id> <slug> non-zero exit=<n> dur=<ms>ms stdout=... stderr=...` on script failure
- `[WRN] Run <id> <slug> failed to launch: <reason>` on launch failure
- `[WRN] Run <id> <slug> timed out after <s>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
+90
View File
@@ -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
```
+76
View File
@@ -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: 210 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.
+124
View File
@@ -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;
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

+68
View File
@@ -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
+78
View File
@@ -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
}
+138
View File
@@ -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)"
+147
View File
@@ -0,0 +1,147 @@
<#
.SYNOPSIS
Mirrors the in-repo docs/ folder to a GitHub or Gitea wiki repo.
.DESCRIPTION
Wikis are separate git repositories (e.g. <repo>.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'
)
$ErrorActionPreference = 'Stop'
$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
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."
}
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.
git add -A
$changes = git status --porcelain
if (-not $changes) {
Write-Host "Wiki already up to date."
return
}
$sha = git -C $repoRoot rev-parse --short HEAD
git -c "user.name=$AuthorName" -c "user.email=$AuthorEmail" commit -q -m "Sync from docs/ at $sha"
git push --quiet
Write-Host "Pushed updated wiki."
}
finally { Pop-Location }
}
finally {
Remove-Item -Recurse -Force $workDir -ErrorAction SilentlyContinue
}
@@ -23,6 +23,28 @@ public static class AdminOps
public const string BindHttps = "bind-https"; public const string BindHttps = "bind-https";
public const string RestartListener = "restart-listener"; public const string RestartListener = "restart-listener";
public const string Ping = "ping"; 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 public sealed class AdminRequest
@@ -38,6 +38,32 @@ public sealed class ConfigStore
var dir = System.IO.Path.GetDirectoryName(Path); var dir = System.IO.Path.GetDirectoryName(Path);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); 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"; var tmp = Path + ".tmp";
await using (var fs = File.Create(tmp)) await using (var fs = File.Create(tmp))
{ {
@@ -49,6 +75,24 @@ public sealed class ConfigStore
File.Move(tmp, Path, overwrite: true); 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) public static void ClearPlaintexts(ServerConfig config)
{ {
foreach (var ep in config.Endpoints) foreach (var ep in config.Endpoints)
-8
View File
@@ -1,13 +1,5 @@
using System.Configuration;
using System.Data;
using System.Windows;
namespace WebhookServer.Gui; namespace WebhookServer.Gui;
/// <summary>
/// Interaction logic for App.xaml
/// </summary>
public partial class App : Application public partial class App : Application
{ {
} }
@@ -1,6 +1,7 @@
using System.Globalization; using System.Globalization;
using System.Windows.Data; using System.Windows.Data;
using System.Windows.Media; using Brush = System.Windows.Media.Brush;
using Brushes = System.Windows.Media.Brushes;
namespace WebhookServer.Gui.Converters; namespace WebhookServer.Gui.Converters;
+15
View File
@@ -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;
+6 -3
View File
@@ -7,6 +7,7 @@
xmlns:models="clr-namespace:WebhookServer.Core.Models;assembly=WebhookServer.Core" xmlns:models="clr-namespace:WebhookServer.Core.Models;assembly=WebhookServer.Core"
mc:Ignorable="d" mc:Ignorable="d"
Title="Webhook Server" Height="600" Width="1000" Title="Webhook Server" Height="600" Width="1000"
Icon="/webhook-server.ico"
d:DataContext="{d:DesignInstance Type=vm:MainViewModel}"> d:DataContext="{d:DesignInstance Type=vm:MainViewModel}">
<Window.InputBindings> <Window.InputBindings>
<KeyBinding Key="N" Modifiers="Control" Command="{Binding AddEndpointCommand}"/> <KeyBinding Key="N" Modifiers="Control" Command="{Binding AddEndpointCommand}"/>
@@ -26,9 +27,9 @@
<MenuItem Header="_File"> <MenuItem Header="_File">
<MenuItem Header="_New endpoint…" Command="{Binding AddEndpointCommand}" InputGestureText="Ctrl+N"/> <MenuItem Header="_New endpoint…" Command="{Binding AddEndpointCommand}" InputGestureText="Ctrl+N"/>
<Separator/> <Separator/>
<MenuItem Header="_Import config…" IsEnabled="False" ToolTip="Coming soon"/> <MenuItem Header="_Import config…" Command="{Binding ImportConfigCommand}"/>
<MenuItem Header="_Export config…" IsEnabled="False" ToolTip="Coming soon"/> <MenuItem Header="_Export config…" Command="{Binding ExportConfigCommand}"/>
<MenuItem Header="_Backups" IsEnabled="False" ToolTip="Coming soon"/> <MenuItem Header="Config _Checkpoints…" Command="{Binding ShowConfigCheckpointsCommand}"/>
<Separator/> <Separator/>
<MenuItem Header="E_xit" Command="{Binding ExitCommand}"/> <MenuItem Header="E_xit" Command="{Binding ExitCommand}"/>
</MenuItem> </MenuItem>
@@ -38,6 +39,8 @@
<MenuItem Header="_Restart service" Command="{Binding RestartServiceCommand}"/> <MenuItem Header="_Restart service" Command="{Binding RestartServiceCommand}"/>
</MenuItem> </MenuItem>
<MenuItem Header="_Help"> <MenuItem Header="_Help">
<MenuItem Header="_Documentation…" Command="{Binding OpenDocumentationCommand}"/>
<Separator/>
<MenuItem Header="_About Webhook Server…" Command="{Binding ShowAboutCommand}"/> <MenuItem Header="_About Webhook Server…" Command="{Binding ShowAboutCommand}"/>
</MenuItem> </MenuItem>
</Menu> </Menu>
+29 -3
View File
@@ -8,12 +8,37 @@ namespace WebhookServer.Gui;
public partial class MainWindow : Window public partial class MainWindow : Window
{ {
private readonly TrayIcon _tray;
private readonly MainViewModel _vm;
public MainWindow() public MainWindow()
{ {
InitializeComponent(); InitializeComponent();
var vm = new MainViewModel(new AdminPipeClient()); _vm = new MainViewModel(new AdminPipeClient());
DataContext = vm; DataContext = _vm;
Loaded += async (_, _) => await vm.RefreshCommand.ExecuteAsync(null);
_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) 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)) if (DataContext is MainViewModel vm && vm.EditEndpointCommand.CanExecute(null))
vm.EditEndpointCommand.Execute(null); vm.EditEndpointCommand.Execute(null);
} }
} }
@@ -86,4 +86,21 @@ public sealed class AdminPipeClient
var lst = resp.Data.Value.GetProperty("lines").Deserialize<List<LogLine>>(AdminProtocol.JsonOptions); var lst = resp.Data.Value.GetProperty("lines").Deserialize<List<LogLine>>(AdminProtocol.JsonOptions);
return lst ?? new List<LogLine>(); return lst ?? new List<LogLine>();
} }
public async Task<List<BackupEntry>> ListBackupsAsync(CancellationToken ct = default)
{
var resp = await InvokeAsync(AdminOps.ListBackups, null, ct).ConfigureAwait(false);
if (!resp.Ok || resp.Data is null) return new List<BackupEntry>();
var lst = resp.Data.Value.GetProperty("backups").Deserialize<List<BackupEntry>>(AdminProtocol.JsonOptions);
return lst ?? new List<BackupEntry>();
}
public Task<AdminResponse> RestoreBackupAsync(string fileName, CancellationToken ct = default) =>
InvokeAsync(AdminOps.RestoreBackup, new RestoreBackupArgs { FileName = fileName }, ct);
public Task<AdminResponse> ImportConfigAsync(ServerConfig config, CancellationToken ct = default) =>
InvokeAsync(AdminOps.ImportConfig, config, ct);
public Task<BackupEntry?> CreateCheckpointAsync(string? description, CancellationToken ct = default) =>
InvokeAsync<BackupEntry>(AdminOps.CreateCheckpoint, new CreateCheckpointArgs { Description = description }, ct);
} }
@@ -0,0 +1,86 @@
using System.Drawing;
using System.Runtime.Versioning;
using System.Windows;
using System.Windows.Forms;
namespace WebhookServer.Gui.Services;
/// <summary>
/// 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.
/// </summary>
[SupportedOSPlatform("windows")]
public sealed class TrayIcon : IDisposable
{
private readonly NotifyIcon _icon;
private readonly Func<Window?> _resolveMainWindow;
private readonly Func<Task> _restartServiceAsync;
public TrayIcon(Func<Window?> resolveMainWindow, Func<Task> 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();
}
}
@@ -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<BackupEntry> 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));
}
}
}
@@ -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<ServerConfig>(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] [RelayCommand]
private async Task RestartServiceAsync() private async Task RestartServiceAsync()
{ {
@@ -204,6 +269,23 @@ public sealed partial class MainViewModel : ObservableObject
dlg.ShowDialog(); 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] [RelayCommand]
private void Exit() private void Exit()
{ {
+2 -1
View File
@@ -2,9 +2,10 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="About Webhook Server" Title="About Webhook Server"
Height="320" Width="420" Height="360" Width="440"
ResizeMode="NoResize" ResizeMode="NoResize"
WindowStartupLocation="CenterOwner" WindowStartupLocation="CenterOwner"
Icon="/webhook-server.ico"
ShowInTaskbar="False"> ShowInTaskbar="False">
<Grid Margin="20"> <Grid Margin="20">
<Grid.RowDefinitions> <Grid.RowDefinitions>
@@ -0,0 +1,53 @@
<Window x:Class="WebhookServer.Gui.Views.ConfigCheckpointsDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="clr-namespace:WebhookServer.Gui.ViewModels"
mc:Ignorable="d"
Title="Config Checkpoints"
Height="500" Width="640"
Icon="/webhook-server.ico"
WindowStartupLocation="CenterOwner"
d:DataContext="{d:DesignInstance Type=vm:ConfigCheckpointsViewModel}">
<DockPanel Margin="12">
<TextBlock DockPanel.Dock="Top" TextWrapping="Wrap" Margin="0,0,0,8" Foreground="#444">
A checkpoint is a snapshot of <Bold>config.json</Bold> taken before each save and once a day at midnight.
Pick one and click <Bold>Roll Back</Bold> to restore it. The current configuration is automatically saved
as a new checkpoint before any rollback, so you can always roll forward again.
</TextBlock>
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,8,0,0">
<Button Content="Take Checkpoint Now" Command="{Binding TakeCheckpointCommand}" Margin="0,0,8,0" Padding="10,4"/>
<Button Content="Roll Back" Command="{Binding RollbackCommand}"
IsEnabled="{Binding Selected, Converter={StaticResource NotNull}}"
Margin="0,0,8,0" Padding="10,4"/>
<Button Content="Close" IsCancel="True" Click="OnClose" Padding="10,4"/>
</StackPanel>
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" Margin="0,4,0,0">
<Button Content="Refresh" Command="{Binding RefreshCommand}" Padding="8,2"/>
<TextBlock Text="{Binding StatusMessage}" Foreground="Gray" FontStyle="Italic" VerticalAlignment="Center" Margin="12,0,0,0"/>
</StackPanel>
<DataGrid ItemsSource="{Binding Checkpoints}"
SelectedItem="{Binding Selected, Mode=TwoWay}"
AutoGenerateColumns="False"
CanUserAddRows="False"
CanUserDeleteRows="False"
IsReadOnly="True"
HeadersVisibility="Column"
GridLinesVisibility="Horizontal">
<DataGrid.Columns>
<DataGridTextColumn Header="When (local)" Width="170"
Binding="{Binding SavedAt, StringFormat='{}{0:yyyy-MM-dd HH:mm:ss}', ConverterCulture=en-US}"/>
<DataGridTextColumn Header="Description" Width="*"
Binding="{Binding Description}"/>
<DataGridTextColumn Header="Size" Width="100"
Binding="{Binding SizeBytes, StringFormat='{}{0:n0} bytes'}"/>
<DataGridTextColumn Header="File name" Width="200"
Binding="{Binding FileName}" FontFamily="Consolas"/>
</DataGrid.Columns>
</DataGrid>
</DockPanel>
</Window>
@@ -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();
}
@@ -0,0 +1,34 @@
<Window x:Class="WebhookServer.Gui.Views.TakeCheckpointDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Take checkpoint"
Height="180" Width="440"
ResizeMode="NoResize"
WindowStartupLocation="CenterOwner"
Icon="/webhook-server.ico"
ShowInTaskbar="False">
<Grid Margin="16">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" TextWrapping="Wrap"
Text="Description for this checkpoint (optional):"/>
<TextBox x:Name="DescriptionBox" Grid.Row="1" Margin="0,8,0,0" MaxLength="120">
<TextBox.InputBindings>
<KeyBinding Key="Enter" Command="{Binding OkCommand, ElementName=Self, FallbackValue={x:Null}}"/>
</TextBox.InputBindings>
</TextBox>
<TextBlock Grid.Row="2" Foreground="Gray" FontStyle="Italic" FontSize="11" Margin="0,4,0,0"
Text="Examples: 'Before adding new endpoint', 'Pre-AD-policy-change'. Leave blank to use 'Manual checkpoint'."/>
<StackPanel Grid.Row="4" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0">
<Button Content="OK" Width="80" IsDefault="True" Click="OnOk" Margin="0,0,8,0"/>
<Button Content="Cancel" Width="80" IsCancel="True" Click="OnCancel"/>
</StackPanel>
</Grid>
</Window>
@@ -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();
}
}
@@ -14,6 +14,15 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF> <UseWPF>true</UseWPF>
<UseWindowsForms>true</UseWindowsForms>
<ApplicationIcon>..\..\resources\webhook-server.ico</ApplicationIcon>
<ApplicationManifest>app.manifest</ApplicationManifest>
<AssemblyTitle>Webhook Server</AssemblyTitle>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<Resource Include="..\..\resources\webhook-server.ico" Link="webhook-server.ico" />
<Resource Include="..\..\resources\webhook-server.png" Link="webhook-server.png" />
</ItemGroup>
</Project> </Project>
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="WebhookServer.Gui"/>
<!-- The GUI talks to the service via a named pipe ACL'd to SYSTEM and the
Administrators group. UAC token splitting denies that group on the
standard user token, so without elevation the pipe connect fails with
"Access is denied". Always run elevated. Start Menu shortcuts and the
installer's post-install launch both honor this. -->
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v2">
<requestedExecutionLevel level="requireAdministrator" uiAccess="false"/>
</requestedPrivileges>
</security>
</trustInfo>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- Windows 10 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
</application>
</compatibility>
</assembly>
@@ -202,11 +202,128 @@ internal sealed class AdminPipeServer : BackgroundService
return AdminResponse.Success(new { lines }); return AdminResponse.Success(new { lines });
} }
case AdminOps.ListBackups:
{
var entries = ListBackups();
return AdminResponse.Success(new { backups = entries });
}
case AdminOps.RestoreBackup:
{
var args = DeserializeData<RestoreBackupArgs>(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<ServerConfig>(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<CreateCheckpointArgs>(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: default:
return AdminResponse.Failure($"unknown op '{request.Op}'"); return AdminResponse.Failure($"unknown op '{request.Op}'");
} }
} }
/// <summary>
/// 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.
/// </summary>
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<BackupEntry> ListBackups()
{
var dir = Path.Combine(ServicePaths.DataRoot, "backups");
if (!Directory.Exists(dir)) return new List<BackupEntry>();
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<ServerConfig> 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<ServerConfig>(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() private ServerConfig CloneSnapshotForEdit()
{ {
// Round-trip via JSON to avoid sharing references with the live snapshot. // Round-trip via JSON to avoid sharing references with the live snapshot.
@@ -0,0 +1,50 @@
using System.Runtime.Versioning;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace WebhookServer.Service;
/// <summary>
/// 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.
/// </summary>
[SupportedOSPlatform("windows")]
internal sealed class CheckpointScheduler : BackgroundService
{
private readonly ILogger<CheckpointScheduler> _logger;
public CheckpointScheduler(ILogger<CheckpointScheduler> 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");
}
}
}
}
+1
View File
@@ -49,6 +49,7 @@ try
builder.Services.AddSingleton<WebhookRouter>(); builder.Services.AddSingleton<WebhookRouter>();
builder.Services.AddHostedService<CallbackBackgroundService>(); builder.Services.AddHostedService<CallbackBackgroundService>();
builder.Services.AddHostedService<AdminPipeServer>(); builder.Services.AddHostedService<AdminPipeServer>();
builder.Services.AddHostedService<CheckpointScheduler>();
var app = builder.Build(); var app = builder.Build();