Documentation: install/upgrade/uninstall guides + recipes incl. Zerto
Adds a docs/ folder under the repo root with full operator documentation aimed at sysadmins (not webhook developers). The Zerto pre/post script recipe is the canonical "why does this exist" walkthrough; the GitHub HMAC, AD password reset, and UI-on-desktop recipes round out common patterns. Pages: - README.md (index) - concepts.md (5-minute "what is a webhook" explainer) - installation.md (interactive + silent install) - upgrading.md (single-click upgrade flow + edge cases) - uninstalling.md (clean removal + wiping ProgramData) - runas-modes.md (Service / InteractiveUser / SpecificUser decision flow) - service-account-and-ad.md (gMSA setup, delegated rights) - network-and-security.md (bind addresses, allowlists, HTTPS, secret storage) - troubleshooting.md (symptom -> first check, common errors) - recipes/zerto-pre-post-scripts.md (canonical use case) - recipes/github-style-hmac.md (GitHub / Stripe-shaped webhooks) - recipes/ad-password-reset.md (gMSA-backed self-service reset) - recipes/ui-on-desktop.md (InteractiveUser pattern) Top-level README.md restructured to point at docs/ as the source of truth, dropping the duplicated installation snippets. Installer ships docs/ alongside the binaries so they're available offline at C:\Program Files\WebhookServer\docs\. GUI Help menu gains a "Documentation" item that opens the docs site in a browser. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
# Recipe: AD password reset endpoint
|
||||
|
||||
A self-service password reset URL your help-desk tool can hit. Single endpoint, gMSA-backed, audited.
|
||||
|
||||
## Architecture
|
||||
|
||||
- The webhook host is domain-joined
|
||||
- The service runs as a gMSA with **Reset Password** + **Write pwdLastSet** delegated on the OUs containing target users
|
||||
- The endpoint is HMAC-signed, IP-allowlisted to the help-desk app's server
|
||||
- Every reset is logged in the daily log file with caller IP, target user, runId, and result
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- gMSA created and installed on the host. See [Service account & Active Directory](../service-account-and-ad.md).
|
||||
- Service installed with `-ServiceAccount 'CONTOSO\svc-webhookserver$'`
|
||||
- Delegate the right permissions on the OU(s):
|
||||
|
||||
```powershell
|
||||
$ou = "OU=Standard Users,DC=contoso,DC=local"
|
||||
dsacls $ou /I:S /G "CONTOSO\svc-webhookserver$:CA;Reset Password;user"
|
||||
dsacls $ou /I:S /G "CONTOSO\svc-webhookserver$:WP;pwdLastSet;user"
|
||||
```
|
||||
|
||||
## The script
|
||||
|
||||
`C:\Scripts\ad-password-reset.ps1`:
|
||||
|
||||
```powershell
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Import-Module ActiveDirectory
|
||||
|
||||
$body = $input | ConvertFrom-Json
|
||||
|
||||
if (-not $body.samAccountName) { throw 'samAccountName is required' }
|
||||
if (-not $body.newPassword) { throw 'newPassword is required' }
|
||||
if (-not $body.requestedBy) { throw 'requestedBy is required (audit field)' }
|
||||
|
||||
# Refuse to touch privileged groups
|
||||
$user = Get-ADUser -Identity $body.samAccountName -Properties MemberOf
|
||||
$denyGroups = @('Domain Admins','Enterprise Admins','Schema Admins')
|
||||
foreach ($g in $user.MemberOf) {
|
||||
$name = ($g -split ',')[0] -replace '^CN='
|
||||
if ($denyGroups -contains $name) {
|
||||
throw "refusing to reset password for member of $name"
|
||||
}
|
||||
}
|
||||
|
||||
$secure = ConvertTo-SecureString $body.newPassword -AsPlainText -Force
|
||||
Set-ADAccountPassword -Identity $user -NewPassword $secure -Reset
|
||||
Set-ADUser -Identity $user -ChangePasswordAtLogon $true
|
||||
|
||||
# Audit line goes to the webhook log automatically (return value becomes stdout).
|
||||
"reset $($user.SamAccountName) requested by $($body.requestedBy)"
|
||||
```
|
||||
|
||||
## Endpoint configuration
|
||||
|
||||
| Section | Setting | Value |
|
||||
|---|---|---|
|
||||
| Identity | Slug | `ad-reset` |
|
||||
| Auth | Mode | **HMAC** with a strong secret shared with the help-desk app |
|
||||
| Auth | HMAC header | `X-Signature-256` |
|
||||
| Auth | HMAC prefix | `sha256=` |
|
||||
| Auth | HMAC encoding | hex |
|
||||
| Allowed clients | | `10.50.10.20` *(the help-desk app's IP only)* |
|
||||
| Executor | Type | Windows PowerShell |
|
||||
| Executor | Script path | `C:\Scripts\ad-password-reset.ps1` |
|
||||
| Data passing | JSON body to stdin | ✓ |
|
||||
| Data passing | Headers/query as env vars | ✗ |
|
||||
| Run as | Identity | **Service** *(uses the gMSA)* |
|
||||
| Response | Mode | Sync |
|
||||
| Response | Timeout (sec) | 30 |
|
||||
| Response | Fail on non-zero exit | ✓ |
|
||||
|
||||
## Calling it
|
||||
|
||||
```powershell
|
||||
$body = @{
|
||||
samAccountName = 'jdoe'
|
||||
newPassword = 'TempP@ssw0rd!2026'
|
||||
requestedBy = 'helpdesk_user@contoso.local'
|
||||
} | ConvertTo-Json
|
||||
|
||||
$bytes = [Text.Encoding]::UTF8.GetBytes($body)
|
||||
$hmac = [Security.Cryptography.HMACSHA256]::new(
|
||||
[Text.Encoding]::UTF8.GetBytes('your-shared-secret'))
|
||||
$sig = ([BitConverter]::ToString($hmac.ComputeHash($bytes)) -replace '-','').ToLower()
|
||||
|
||||
Invoke-RestMethod -Method POST `
|
||||
-Uri 'http://webhooks.contoso.local:8080/hook/ad-reset' `
|
||||
-Headers @{ 'X-Signature-256' = "sha256=$sig" } `
|
||||
-ContentType 'application/json' -Body $body
|
||||
```
|
||||
|
||||
## Operational notes
|
||||
|
||||
**Audit log**: every call lands in `C:\ProgramData\WebhookServer\logs\webhook-YYYYMMDD.log` with one line per run including the runId, slug, caller IP, exit code, and the script's stdout (the `"reset jdoe requested by helpdesk_user"` line). Ship those logs to your SIEM via the usual file-collector flow.
|
||||
|
||||
**Rotating the HMAC secret**: edit the endpoint in the GUI, replace the secret, save. The help-desk app needs the new secret too — coordinate the cutover. There's no overlap window built in; if you need a soft rollover, create a second endpoint with the new secret and switch caller traffic over.
|
||||
|
||||
**Privileged-group guard**: the script's `denyGroups` check is a basic guard. If a more sophisticated guard is needed (target user attribute, OU-based logic), add it in the script — that's the right place, not the webhook server.
|
||||
|
||||
**Self-service from the user side**: don't expose this endpoint to end users directly. Front it with a help-desk app that authenticates the user (preferably with MFA), then makes the call to the webhook with its bearer/HMAC credentials. The webhook server is the *plumbing*; not the *front door*.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,220 @@
|
||||
# Recipe: Zerto pre/post scripts → AD / DNS update
|
||||
|
||||
This is the canonical reason Webhook Server exists. Zerto's failover, move, and clone operations support pre- and post-scripts — but those scripts run on the Zerto Virtual Manager (ZVM), not on the destination domain controller or DNS server. To touch AD or DNS during a failover you need either:
|
||||
|
||||
- A bastion / utility host with the right modules and credentials installed (and you accept the maintenance burden of keeping its scripts in sync)
|
||||
- **A webhook on a Windows host** — Zerto's pre/post calls a single URL, and the webhook server runs the right PowerShell on the right machine with the right identity. This page is about that.
|
||||
|
||||
## What we're building
|
||||
|
||||
A Zerto pre/post script POSTs to `http://webhooks.contoso.local:8080/hook/dr-failover-prep` with a JSON body identifying the VPG and target VMs. The webhook server, running on a domain-joined utility host as a gMSA with delegated AD rights, runs PowerShell that:
|
||||
|
||||
1. Updates AD computer object descriptions to indicate they're now at the DR site
|
||||
2. Updates DNS A records to point `app01.contoso.local` and friends at the new (DR) IPs
|
||||
3. Posts a result line to a Teams channel
|
||||
4. Returns 200 with the summary so it shows up in Zerto's pre/post script log
|
||||
|
||||
It's about ~30 lines of PowerShell on the server side and 3 lines of script in Zerto.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
On the webhook host:
|
||||
|
||||
- Webhook Server installed (see [Installation](../installation.md))
|
||||
- The host is domain-joined
|
||||
- The service account has the **AD permissions** it needs. We'll configure this two ways below — the simple way (LocalSystem + delegated rights to the machine account) and the production way (gMSA).
|
||||
- DNS PowerShell module installed if you'll modify DNS: `Install-WindowsFeature RSAT-DNS-Server` (Server) or RSAT installed (Win 10/11).
|
||||
- AD PowerShell module: `Install-WindowsFeature RSAT-AD-PowerShell` (Server).
|
||||
|
||||
On the Zerto side:
|
||||
|
||||
- ZVM 8.x or 9.x (this works with both)
|
||||
- A Virtual Protection Group (VPG) you want to wire up
|
||||
|
||||
## 1. Plan the script and the inputs
|
||||
|
||||
What does the script need to know? At minimum:
|
||||
|
||||
- **VPG name** — Zerto exposes this as a parameter to the pre/post script
|
||||
- **VM names** — likewise
|
||||
- **Target IPs** — depending on your failover topology, these may be static (DR network has known IPs) or known after Zerto reconfigures the IP
|
||||
|
||||
Decide what travels in the request body and what's hardcoded. A pragmatic split:
|
||||
|
||||
- Hardcoded (in the PowerShell script on the webhook host): zone name, AD OU, Teams webhook URL, mapping table from VM hostname → target IP
|
||||
- Sent in the body: VPG name, list of VM names, an "operation" field (`failover`, `move`, `failback`, etc.)
|
||||
|
||||
Example body the Zerto script will send:
|
||||
|
||||
```json
|
||||
{
|
||||
"operation": "failover",
|
||||
"vpg": "App-Production",
|
||||
"vms": ["app01", "app02", "db01"]
|
||||
}
|
||||
```
|
||||
|
||||
## 2. Write the PowerShell script on the webhook host
|
||||
|
||||
Save this as `C:\Scripts\dr-failover-prep.ps1` on the webhook host:
|
||||
|
||||
```powershell
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# Read the body from stdin (the webhook server pipes the JSON in for us when
|
||||
# StdinJson is enabled).
|
||||
$body = $input | ConvertFrom-Json
|
||||
|
||||
# Hardcoded site config - edit for your environment.
|
||||
$dnsServer = 'dc01.contoso.local'
|
||||
$forwardZone = 'contoso.local'
|
||||
$adOu = 'OU=Servers,DC=contoso,DC=local'
|
||||
$teamsWebhook = 'https://contoso.webhook.office.com/...' # one-way, no secret to leak
|
||||
$drIpMap = @{
|
||||
'app01' = '10.42.10.11'
|
||||
'app02' = '10.42.10.12'
|
||||
'db01' = '10.42.10.21'
|
||||
}
|
||||
|
||||
$summary = @()
|
||||
|
||||
foreach ($vm in $body.vms) {
|
||||
if (-not $drIpMap.ContainsKey($vm)) {
|
||||
$summary += "skip $vm - no DR IP mapping"
|
||||
continue
|
||||
}
|
||||
$newIp = $drIpMap[$vm]
|
||||
|
||||
# 1. Update DNS A record (delete + recreate is the simplest reliable path)
|
||||
$existing = Get-DnsServerResourceRecord -ZoneName $forwardZone -Name $vm `
|
||||
-RRType A -ComputerName $dnsServer -ErrorAction SilentlyContinue
|
||||
if ($existing) {
|
||||
Remove-DnsServerResourceRecord -ZoneName $forwardZone -Name $vm `
|
||||
-RRType A -RecordData $existing.RecordData.IPv4Address `
|
||||
-ComputerName $dnsServer -Force
|
||||
}
|
||||
Add-DnsServerResourceRecordA -ZoneName $forwardZone -Name $vm `
|
||||
-IPv4Address $newIp -ComputerName $dnsServer -TimeToLive 00:05:00
|
||||
|
||||
# 2. Update AD computer description so on-call can see at a glance
|
||||
Set-ADComputer -Identity $vm -Description "[DR-$($body.operation)] $(Get-Date -Format s)"
|
||||
|
||||
$summary += "ok $vm -> $newIp"
|
||||
}
|
||||
|
||||
# 3. Notify Teams
|
||||
$msg = @{
|
||||
text = "Webhook DR prep for VPG **$($body.vpg)** ($($body.operation)):`n" +
|
||||
($summary -join "`n")
|
||||
} | ConvertTo-Json
|
||||
Invoke-RestMethod -Uri $teamsWebhook -Method POST -ContentType 'application/json' -Body $msg | Out-Null
|
||||
|
||||
# 4. Print the summary so Zerto's pre/post script log captures it
|
||||
$summary -join "`n"
|
||||
```
|
||||
|
||||
A few choices worth calling out:
|
||||
|
||||
- **`$input | ConvertFrom-Json`** — Webhook Server pipes the request body into the script via stdin when "JSON body to stdin" is ticked. `$input` is PowerShell's automatic variable for pipeline input.
|
||||
- **`$ErrorActionPreference = 'Stop'`** — turn cmdlet warnings into terminating errors so the script exits non-zero on real problems. Webhook Server then returns 502 (configurable via "Fail on non-zero exit") and Zerto sees the failure.
|
||||
- **Two-way Teams notification but one-way return** — the script's stdout becomes the HTTP response. Zerto logs it. The Teams notification is a separate Invoke-RestMethod.
|
||||
|
||||
## 3. Configure the endpoint in the GUI
|
||||
|
||||
In Webhook Server's GUI, **File → New endpoint**:
|
||||
|
||||
| Section | Setting | Value |
|
||||
|---|---|---|
|
||||
| Identity | Slug | `dr-failover-prep` |
|
||||
| Identity | Description | "Zerto pre-script: update AD/DNS during failover" |
|
||||
| Auth | Mode | **Bearer** |
|
||||
| Auth | Bearer secret | generate a 32-byte random string; copy it for the Zerto script |
|
||||
| Allowed clients | (one per line) | `10.0.0.0/8` (your ZVM's network) |
|
||||
| Executor | Type | **Windows PowerShell** |
|
||||
| Executor | Script path | `C:\Scripts\dr-failover-prep.ps1` |
|
||||
| Data passing | JSON body to stdin | ✓ |
|
||||
| Data passing | Headers/query as env vars | ✗ |
|
||||
| Run as | Identity | **Service** if the service is running as a gMSA with AD rights, otherwise **SpecificUser** with a delegated account |
|
||||
| Response | Mode | **Sync** |
|
||||
| Response | Timeout (sec) | `60` |
|
||||
| Response | Fail on non-zero exit | ✓ |
|
||||
|
||||
Save. Right-click the row → **Copy URL** to grab the full URL, e.g. `http://webhooks.contoso.local:8080/hook/dr-failover-prep`.
|
||||
|
||||
> **Why Bearer auth and not None?** Even though the IP allowlist limits who can reach this endpoint, the Bearer token is a defense-in-depth layer. If someone managed to spoof or get on the trusted network, they still need the token. Generate it once, store it in a secrets manager (or in Zerto's encrypted script parameters), and never email it.
|
||||
|
||||
## 4. The Zerto pre/post script
|
||||
|
||||
Zerto pre/post scripts are PowerShell files placed on the ZVM. The path varies by Zerto version; in 9.x it's typically `C:\Program Files\Zerto\Zerto Virtual Replication\Scripts\`.
|
||||
|
||||
Create `dr-failover-prep.ps1` on the ZVM:
|
||||
|
||||
```powershell
|
||||
# Zerto passes context as parameters/environment - exact names vary by version.
|
||||
# Document yours; this is illustrative.
|
||||
param(
|
||||
[string]$VpgName = $env:ZertoVPGName
|
||||
)
|
||||
|
||||
$webhookUrl = 'http://webhooks.contoso.local:8080/hook/dr-failover-prep'
|
||||
$bearer = 'paste-the-bearer-secret-here' # store via Zerto secret param if available
|
||||
|
||||
# Build the body. In a real script, list the VMs by querying Zerto's API or by
|
||||
# convention from the VPG name.
|
||||
$body = @{
|
||||
operation = 'failover'
|
||||
vpg = $VpgName
|
||||
vms = @('app01','app02','db01')
|
||||
} | ConvertTo-Json
|
||||
|
||||
$response = Invoke-RestMethod -Method POST -Uri $webhookUrl -Body $body `
|
||||
-ContentType 'application/json' -TimeoutSec 90 `
|
||||
-Headers @{ Authorization = "Bearer $bearer" }
|
||||
|
||||
# Print whatever the webhook returned to Zerto's log.
|
||||
$response.stdout
|
||||
```
|
||||
|
||||
Wire this script into your VPG's **Pre-Recovery** or **Post-Recovery** hook in the Zerto UI.
|
||||
|
||||
## 5. Test before going live
|
||||
|
||||
In a maintenance window, hit the endpoint manually with a fake VPG name to confirm the wiring works:
|
||||
|
||||
```powershell
|
||||
$body = @{ operation='test'; vpg='SmokeTest'; vms=@('app01') } | ConvertTo-Json
|
||||
Invoke-RestMethod -Method POST `
|
||||
-Uri http://webhooks.contoso.local:8080/hook/dr-failover-prep `
|
||||
-Headers @{ Authorization = "Bearer paste-the-secret" } `
|
||||
-ContentType application/json -Body $body
|
||||
```
|
||||
|
||||
You should see the summary line(s) come back, AD descriptions update, DNS A records update, and a Teams notification. If anything's off:
|
||||
|
||||
- **No response, hang** → check the GUI's log panel. The auto-poll updates every 3 seconds. Look for the run line with the slug + exit code.
|
||||
- **401 Unauthorized** → bearer mismatch
|
||||
- **403 Forbidden** → IP allowlist blocking you
|
||||
- **502 Bad Gateway** → script ran but exited non-zero. The response body has stderr.
|
||||
|
||||
After a real failover triggers it, audit by checking the daily log file at `C:\ProgramData\WebhookServer\logs\webhook-YYYYMMDD.log` for the `Run <id> dr-failover-prep ok exit=0` line.
|
||||
|
||||
## Variations
|
||||
|
||||
### Different actions for failover vs. failback
|
||||
|
||||
Pass an `operation` field in the body and branch on it in the PowerShell. The script above already does this — extend the `switch` to handle `failback` (revert DNS to production IPs, clear DR description, etc.).
|
||||
|
||||
### Per-VPG endpoints
|
||||
|
||||
If you want fine-grained access control per VPG, create one endpoint per VPG and give each its own bearer secret. The GUI's grid handles dozens of endpoints fine.
|
||||
|
||||
### Async + callback for long-running work
|
||||
|
||||
If your AD/DNS update genuinely takes minutes (e.g., updating thousands of records in a large environment), set the endpoint to **Async** mode. Zerto's pre-script gets `202 Accepted` immediately and continues. Configure the endpoint's **Callback** with a URL that records the result (e.g., another endpoint that logs to a file, or your monitoring system's API).
|
||||
|
||||
### Audit trail to a SIEM
|
||||
|
||||
Configure each endpoint's **Callback** with your SIEM's HTTP collector URL + an HMAC secret. Every run produces a JSON record with runId, exit code, duration, stdout, and stderr — perfect for compliance audit logs.
|
||||
Reference in New Issue
Block a user