18 Commits

Author SHA1 Message Date
justin 821ff9b9ef Add ZVMA pre/post script recipe + env-dump examples
Adds a Kubernetes-ZVMA companion to the existing Windows-ZVM recipe:

- scripts/examples/zerto-zvma-send.ps1 - Zerto-side sender for both
  pre and post phases, packages the Zerto* env vars into a structured
  JSON body and POSTs to a {phase}-templated webhook URL.
- scripts/examples/zerto-receiver-notify.ps1 - server-side receiver
  that posts a Slack/Teams notification, with phase-aware formatting
  and ZertoForce highlighted on pre.
- scripts/examples/zerto-receiver-vm-healthcheck.ps1 - server-side
  receiver that pings + port-probes each VM in VmDisplayNames after
  failover and writes a per-run JSON report.
- scripts/examples/send-env-vars.ps1 + save-env-vars.ps1 - generic
  env-dump client/receiver pair (the diagnostic that surfaced what
  the ZVMA scripts-service container exposes).
- docs/recipes/zerto-zvma-pre-post.md - full walkthrough mirroring
  the existing Windows-ZVM recipe's structure.
- README.md and docs/README.md - link the new recipe and examples.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 14:16:07 -04:00
justin 4954e94d08 Drop GitHub-only upload-artifact step from Gitea workflow (#17) 2026-05-08 14:12:25 -04:00
justin 10341c63cb Bisect: try minimal .iss before the real one (#16) 2026-05-08 13:49:11 -04:00
justin 10b15fc17c Replace whoami subprocess with .NET API in diag (#15) 2026-05-08 13:41:18 -04:00
justin 1229c52ecf Sync .NET cwd, bake version, pass /O absolute (#13) 2026-05-08 13:35:49 -04:00
justin 14d1bdc461 Capture ISCC stdout+stderr and pre-flight {#RepoRoot} paths (#12) 2026-05-08 13:19:09 -04:00
justin 7c164ab3b3 Sync: ISCC cwd fix (#11) 2026-05-08 13:10:13 -04:00
justin d89290aedb Sync: installer diagnostics (#9) 2026-05-08 12:56:22 -04:00
justin ddd36a9116 Sync from GitHub main: v0.1.4 (#8) 2026-05-08 12:32:42 -04:00
justin b66dd245c0 Sync from GitHub main: Gitea Actions support (#7) 2026-05-08 12:02:59 -04:00
justin 1ea724cd1f Sync from GitHub main: v0.1.3 (#6)
CI / build (push) Has been cancelled
2026-05-08 11:32:20 -04:00
justin a2bd338839 Wiki sync: stop treating git's stderr as fatal (#5)
Sync Wiki / sync (push) Has been cancelled
CI / build (push) Has been cancelled
2026-05-08 11:21:07 -04:00
justin b17d832842 Sync from GitHub main: v0.1.1 + v0.1.2 + wiki sync (#3)
Sync Wiki / sync (push) Has been cancelled
CI / build (push) Has been cancelled
2026-05-08 11:14:17 -04:00
justin fe42f2f908 Merge pull request 'Document service account choices for AD-aware hooks' (#1) from claude/pensive-easley-4abcbe into main
CI / build (push) Has been cancelled
Reviewed-on: #1
2026-05-08 10:05:11 -04:00
justin 93a9c327e0 Phase 4: backups + import/export config
Release / build-installer (push) Has been cancelled
CI / build (pull_request) Has been cancelled
ConfigStore.SaveAsync now snapshots the previous config to
%ProgramData%\WebhookServer\backups\config-<timestamp>.json before
overwriting, retaining the last 30. Failures are silent so a
backup-write hiccup never blocks an actual save.

Three new admin pipe ops:
- list-backups: returns newest 50 entries with timestamps and sizes
- restore-backup: takes a fileName, refuses path-traversal chars,
  loads the named backup over the live config (which itself triggers
  a fresh backup of the current state via the SaveAsync hook)
- import-config: replaces the current config with a GUI-supplied
  ServerConfig, merging encrypted secrets where the GUI didn't supply
  new plaintext

GUI File menu items are wired:
- Import config: file picker -> ImportConfigAsync
- Export config: SaveFileDialog writes the current config as JSON
- Backups: dynamic submenu auto-refreshed when opened, listing
  backups with timestamp + size; click to confirm-and-restore

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 09:55:03 -04:00
justin 9e6abeef74 Phase 6+7: Inno Setup installer + GitHub Actions release pipeline
CI / build (pull_request) Has been cancelled
installer/webhook-server.iss is an Inno Setup 6 script that:
- Installs to %ProgramFiles%\WebhookServer
- Creates Start Menu folder + GUI shortcut (and optional desktop icon)
- Runs install-service.ps1 post-install to register the Windows Service
- Runs uninstall-service.ps1 pre-uninstall to remove it
- Bundles the webhook-server icon for the installer / uninstaller

scripts/build-installer.ps1 is the local build helper: publishes both
projects, finds ISCC.exe (PATH or standard install path), compiles the
installer with the version pulled from Directory.Build.props, drops the
output in dist/.

.github/workflows/ci.yml runs build + test on every push/PR to main.
.github/workflows/release.yml triggers on v* tags (or manual dispatch),
runs tests, installs Inno Setup via choco, builds the installer, and
attaches the .exe to a GitHub Release. Pre-1.0 versions are flagged
prerelease automatically.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 09:52:37 -04:00
justin 9525ee358e Phase 5: tray icon with minimize-to-tray and context menu
GUI csproj enables UseWindowsForms (NotifyIcon lives in WinForms even
in .NET 8). New Services/TrayIcon.cs wraps NotifyIcon with a context
menu (Open / Restart service / Exit) and the embedded webhook-server
icon. MainWindow creates the TrayIcon, hides itself on minimize and
restores on tray double-click.

Adds GlobalUsings.cs to alias the WPF defaults for types that exist
in both WPF and WinForms (Application, MessageBox, TextBox, Binding,
etc.) so existing code keeps compiling without per-file changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 09:51:00 -04:00
justin f3bca1e8ff Phase 3: app icon (multi-resolution ICO + master PNG)
scripts/generate-icons.ps1 renders the icon programmatically with
System.Drawing - rounded teal square (#0E7C66) with a stylized white
hook glyph - at 16/24/32/48/64/128/256 px and assembles a proper
multi-resolution Microsoft ICO. The PNG and ICO outputs land in
resources/. The script is the source of truth; re-run after editing
the design.

GUI csproj uses ApplicationIcon for the EXE icon and embeds the .ico
+ .png as Resources so MainWindow and AboutDialog can use them via
WPF's resource URI scheme.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 09:48:33 -04:00
35 changed files with 1898 additions and 319 deletions
+100
View File
@@ -0,0 +1,100 @@
name: Release (Gitea)
# Lives in .gitea/workflows/ so it runs on Gitea Actions only. The GitHub-side
# release lives in .github/workflows/release.yml.
#
# Triggered automatically on v* tag pushes; can also be invoked manually via
# workflow_dispatch with a version override (useful for testing the runner
# without bumping the project version).
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
version:
description: 'Version to build (e.g. 0.1.4). Defaults to Directory.Build.props.'
required: false
jobs:
build-installer:
runs-on: windows-latest
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
shell: pwsh
run: |
dotnet restore WebhookServer.sln
dotnet test WebhookServer.sln -c Release
- name: Ensure Inno Setup is installed
shell: pwsh
run: |
if (-not (Get-Command iscc -ErrorAction SilentlyContinue) -and `
-not (Test-Path 'C:\Program Files (x86)\Inno Setup 6\ISCC.exe') -and `
-not (Test-Path 'C:\Program Files\Inno Setup 6\ISCC.exe')) {
choco install innosetup --no-progress -y
}
- name: Build installer
shell: pwsh
run: ./scripts/build-installer.ps1 -VersionOverride ${{ steps.ver.outputs.version }}
# actions/upload-artifact@v4 is GitHub-only ("GHESNotSupportedError" on
# Gitea). The release-creation step below attaches the .exe via Gitea's
# API directly, which is the only place we actually need to surface it.
- name: Create Gitea release with installer attached
if: startsWith(github.ref, 'refs/tags/v')
shell: pwsh
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
$version = '${{ steps.ver.outputs.version }}'
$tag = '${{ github.ref_name }}'
$repo = '${{ github.repository }}'
$serverUrl = '${{ github.server_url }}'
$apiBase = "$serverUrl/api/v1/repos/$repo"
$headers = @{ Authorization = "token $env:GITEA_TOKEN" }
# 1. Create the release.
$isPre = $version.StartsWith('0.')
$createBody = @{
tag_name = $tag
name = "Webhook Server $version"
body = "Automated build via Gitea Actions runner."
draft = $false
prerelease = $isPre
} | ConvertTo-Json
$rel = Invoke-RestMethod -Uri "$apiBase/releases" -Method Post `
-Headers $headers -ContentType 'application/json' -Body $createBody
Write-Host "Created release id=$($rel.id) tag=$tag"
# 2. Attach the installer.
$file = Get-Item "dist/WebhookServer-Setup-$version.exe"
$uploadUri = "$apiBase/releases/$($rel.id)/assets?name=$($file.Name)"
Invoke-RestMethod -Uri $uploadUri -Method Post -Headers $headers `
-ContentType 'application/octet-stream' -InFile $file.FullName | Out-Null
Write-Host "Uploaded $($file.Name) ($([math]::Round($file.Length / 1MB, 2)) MB) to $tag"
+1
View File
@@ -5,6 +5,7 @@ on:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
jobs:
build:
+3
View File
@@ -12,6 +12,9 @@ on:
jobs:
build-installer:
# Gitea reads .github/workflows/ for compatibility, but the create-release
# step uses a GitHub-only action. Skip the whole job on non-GitHub runners.
if: github.server_url == 'https://github.com'
runs-on: windows-latest
permissions:
contents: write # needed to create releases / upload assets
+31
View File
@@ -0,0 +1,31 @@
name: Sync Wiki
on:
push:
branches: [main]
paths:
- 'docs/**'
- 'scripts/sync-wiki.ps1'
- '.github/workflows/wiki-sync.yml'
workflow_dispatch:
jobs:
sync:
# Gitea reads .github/workflows/ for compatibility, but this workflow
# pushes to a GitHub-hosted wiki. Skip on non-GitHub runners; the Gitea
# wiki is synced separately via scripts/sync-wiki.ps1.
if: github.server_url == 'https://github.com'
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>
<PropertyGroup>
<Version>0.1.1</Version>
<Version>0.1.4</Version>
<Authors>Justin Paul</Authors>
<Company>Justin Paul</Company>
<Product>Webhook Server</Product>
+4 -2
View File
@@ -61,11 +61,13 @@ Everything you need to operate the server:
Recipes:
- [Zerto pre/post scriptsAD / DNS update](docs/recipes/zerto-pre-post-scripts.md) ← **canonical use case**
- [Zerto failover post-script → DNS + service checks](docs/recipes/zerto-pre-post-scripts.md) ← **canonical use case** (Windows ZVM)
- [Zerto ZVMA (Kubernetes) pre/post → notify + VM health check](docs/recipes/zerto-zvma-pre-post.md) — same pattern for the in-cluster scripts-service
- [GitHub-style HMAC-signed webhook](docs/recipes/github-style-hmac.md)
- [AD password reset endpoint](docs/recipes/ad-password-reset.md)
- [Pop UI on the user's desktop](docs/recipes/ui-on-desktop.md)
Ready-to-drop-in Zerto-side scripts are included at [`scripts/examples/zerto-post-failover.ps1`](scripts/examples/zerto-post-failover.ps1) (Windows ZVM) and [`scripts/examples/zerto-zvma-send.ps1`](scripts/examples/zerto-zvma-send.ps1) (ZVMA / Kubernetes); receiver examples for the ZVMA recipe ship as [`zerto-receiver-notify.ps1`](scripts/examples/zerto-receiver-notify.ps1) and [`zerto-receiver-vm-healthcheck.ps1`](scripts/examples/zerto-receiver-vm-healthcheck.ps1).
## Requirements
- Windows 10 / 11 / Server 2019+
+5 -3
View File
@@ -6,7 +6,7 @@ Webhook Server is a Windows service that runs a script (PowerShell, cmd, or any
1. [Concepts](concepts.md) — five-minute read on what a webhook is and how this server uses one
2. [Installation](installation.md) — download, install, first endpoint
3. [Recipe: Zerto pre/post scriptsAD / DNS update](recipes/zerto-pre-post-scripts.md) — the canonical reason this exists
3. [Recipe: Zerto failover post-script → DNS + service checks](recipes/zerto-pre-post-scripts.md) — the canonical reason this exists
## Topical
@@ -19,11 +19,13 @@ Webhook Server is a Windows service that runs a script (PowerShell, cmd, or any
## Recipes (cookbook style)
- [Zerto pre/post scriptsAD / DNS update](recipes/zerto-pre-post-scripts.md)
- [Zerto failover post-script → DNS + service checks](recipes/zerto-pre-post-scripts.md) ← canonical use case (Windows ZVM)
- [Zerto ZVMA (Kubernetes) pre/post → notify + VM health check](recipes/zerto-zvma-pre-post.md) — same pattern for the in-cluster scripts-service
- [GitHub-style HMAC-signed webhook](recipes/github-style-hmac.md)
- [AD password reset endpoint](recipes/ad-password-reset.md)
- [Pop UI on the user's desktop](recipes/ui-on-desktop.md)
The flagship Zerto recipe ships with a ready-to-use Zerto-side post-script at [`scripts/examples/zerto-post-failover.ps1`](../scripts/examples/zerto-post-failover.ps1). The ZVMA recipe ships with [`zerto-zvma-send.ps1`](../scripts/examples/zerto-zvma-send.ps1) (sender) plus [`zerto-receiver-notify.ps1`](../scripts/examples/zerto-receiver-notify.ps1) and [`zerto-receiver-vm-healthcheck.ps1`](../scripts/examples/zerto-receiver-vm-healthcheck.ps1) (receivers).
## Reference
- [GitHub repo](https://github.com/recklessop/webhook-server)
-105
View File
@@ -1,105 +0,0 @@
# 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*.
+153 -130
View File
@@ -1,94 +1,98 @@
# Recipe: Zerto pre/post scripts AD / DNS update
# Recipe: Zerto failover post-script → DNS update + service checks
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:
This is the canonical reason Webhook Server exists.
- 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.
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
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:
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 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
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
It's about ~30 lines of PowerShell on the server side and 3 lines of script in Zerto.
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.
## Prerequisites
## Why curl and not Invoke-WebRequest?
On the webhook host:
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.
- 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).
## 1. The Zerto post-script (client side)
On the Zerto 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:
- ZVM 8.x or 9.x (this works with both)
- A Virtual Protection Group (VPG) you want to wire up
> **VPG settings → Recovery → Scripts → Post-Recovery Script**
> Path: `C:\Scripts\zerto-post-failover.ps1`
> Parameters: *(leave empty)*
## 1. Plan the script and the inputs
The script is ~50 lines and only depends on `curl.exe` + a token file readable by the ZVM service account.
What does the script need to know? At minimum:
The flow:
- **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"]
}
```
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. Write the PowerShell script on the webhook host
## 2. The server-side script (does the actual work)
Save this as `C:\Scripts\dr-failover-prep.ps1` on the webhook host:
Save this on the webhook host as `C:\Scripts\post-failover-handler.ps1`:
```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.
# ---------- environment specifics; edit for your site ----------
$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
$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 $body.vms) {
foreach ($vm in $vms) {
if (-not $drIpMap.ContainsKey($vm)) {
$summary += "skip $vm - no DR IP mapping"
$summary += "skip $vm (no DR IP mapping in handler)"
continue
}
$newIp = $drIpMap[$vm]
$ip = $drIpMap[$vm]
# 1. Update DNS A record (delete + recreate is the simplest reliable path)
# 1. DNS - delete + re-add the A record
try {
$existing = Get-DnsServerResourceRecord -ZoneName $forwardZone -Name $vm `
-RRType A -ComputerName $dnsServer -ErrorAction SilentlyContinue
if ($existing) {
@@ -97,124 +101,143 @@ foreach ($vm in $body.vms) {
-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"
-IPv4Address $ip -ComputerName $dnsServer -TimeToLive 00:05:00
$summary += "dns $vm -> $ip"
} catch {
$summary += "DNS! $vm $($_.Exception.Message)"
continue
}
# 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
# 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
}
# 4. Print the summary so Zerto's pre/post script log captures it
# 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"
```
A few choices worth calling out:
Two things to call 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.
- **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
In Webhook Server's GUI, **File → New endpoint**:
**File → New endpoint:**
| Section | Setting | Value |
|---|---|---|
| Identity | Slug | `dr-failover-prep` |
| Identity | Description | "Zerto pre-script: update AD/DNS during failover" |
| 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 |
| Allowed clients | (one per line) | `10.0.0.0/8` (your ZVM's network) |
| 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\dr-failover-prep.ps1` |
| Executor | Script path | `C:\Scripts\post-failover-handler.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 | ✓ |
| 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 the full URL, e.g. `http://webhooks.contoso.local:8080/hook/dr-failover-prep`.
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 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.
> **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. The Zerto pre/post script
## 4. Wire up the bearer token
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:
Place the bearer token in a file the ZVM service account can read (and nobody else):
```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
# 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
```
Wire this script into your VPG's **Pre-Recovery** or **Post-Recovery** hook in the Zerto UI.
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, hit the endpoint manually with a fake VPG name to confirm the wiring works:
In a maintenance window, fire the webhook by hand:
```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
# 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 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.
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 in the PowerShell. The script above already does this — extend the `switch` to handle `failback` (revert DNS to production IPs, clear DR description, etc.).
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 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).
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
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.
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.
+277
View File
@@ -0,0 +1,277 @@
# Recipe: Zerto ZVMA (Kubernetes) pre/post scripts → notify + VM health check
> Companion to [Zerto failover post-script → DNS + service checks](zerto-pre-post-scripts.md).
> That recipe targets the **Windows ZVM** (the older deployment, where the
> Zerto-side script is a `.ps1` calling `curl.exe`). **This** recipe targets
> the **ZVMA on Kubernetes** — the newer deployment, where pre/post scripts
> run inside the in-cluster `scripts-service` container (Linux + pwsh 7).
> The webhook-server side is the same Windows service in both cases; only
> the Zerto-side runtime differs.
## What we're building
ZVMA's `scripts-service` pod runs your VPG pre/post scripts inside a Linux
container. It exposes a small set of `Zerto*` environment variables, and we
want to:
1. POST those variables to a Webhook Server endpoint at the start (pre) and
end (post) of every VPG operation, and
2. On the receiving Windows host, do something useful with them — at minimum
a chat notification, and on `post` a quick health check of the VMs that
just powered on.
The endpoints are **Async**, so the Zerto VPG sequence is never blocked by
slow downstream actions (notifications, port probes, etc.).
```
Zerto VPG operation starts
|
+-- ZVMA scripts-service container runs:
| /app/scripts-files/zerto-zvma-send.ps1 -Phase pre
| -> POST http://webhook.dr/hook/zerto-pre (async, returns 202)
|
+-- VMs come up at recovery site
|
+-- ZVMA scripts-service container runs:
/app/scripts-files/zerto-zvma-send.ps1 -Phase post
-> POST http://webhook.dr/hook/zerto-post (async, returns 202)
(meanwhile, on the webhook server)
/hook/zerto-pre -> Slack/Teams notification ("Test failover starting...")
/hook/zerto-post -> Slack/Teams notification + ping/port probe each VM,
write a JSON report to disk, exit non-zero on failure.
```
## What ZVMA exposes
Captured from a real Test failover; same set is present in pre and post:
| Variable | Example | Notes |
|---|---|---|
| `ZertoVPGName` | `ubuntu-2404-local` | The VPG that fired the script |
| `ZertoInternalVpgName` | `ubuntu-2404-local` | Usually identical to `ZertoVPGName` |
| `ZertoOperation` | `Test` | `Test` / `Failover` / `Move` / `FailoverBeforeCommit` / `FailoverDuringCommit` |
| `ZertoForce` | `Yes` (pre) / `No` (post) | Set to `Yes` only during the pre phase when force mode is on; reset to `No` by post |
| `VmDisplayNames` | `ubuntu-2404(1)(1)(1)` | Comma-separated for multi-VM VPGs; Test failovers add `(N)` suffixes |
| `ZertoHypervisorManagerIP` | `192.168.50.20` | The vCenter / Hyper-V manager ZVMA is talking to |
| `ZertoHypervisorManagerPort` | `443` | |
| `ZertoOutputDir` | `/app/scripts-output` | Container-side output dir (written back to ZVMA via PVC) |
| `ZertoWorkingDir` | `/app/scripts-files` | Where script files live in-container |
Branch on `ZertoOperation` to differentiate Test runs from real failovers.
**`ZertoForce` is only meaningful during the pre phase** — capture it there
if you need it later, because by post it's been reset.
## 1. The Zerto-side script (sender)
A ready-to-use script ships in this repo at
[`scripts/examples/zerto-zvma-send.ps1`](../../scripts/examples/zerto-zvma-send.ps1).
Place it where the `scripts-service` pod can read it — typically the
`scripts-service-scripts-files-pvc`, mounted at `/app/scripts-files/` — and
wire it into the VPG twice:
> **VPG settings → Recovery → Scripts → Pre-Recovery Script**
> Path: `/app/scripts-files/zerto-zvma-send.ps1`
> Parameters: `-Phase pre`
>
> **VPG settings → Recovery → Scripts → Post-Recovery Script**
> Path: `/app/scripts-files/zerto-zvma-send.ps1`
> Parameters: `-Phase post`
The default `$WebhookUrl` includes `{phase}` so one script + one URL config
serves both phases — `http://webhook.dr/hook/zerto-{phase}` becomes
`/hook/zerto-pre` and `/hook/zerto-post` automatically. Override with
`-WebhookUrl` and `-Bearer` if you'd rather pass them per-VPG.
The script POSTs a single JSON object:
```json
{
"phase": "pre",
"capturedAt": "2026-05-08T17:45:54Z",
"host": "scripts-service-f9b6cb7-4xbxq",
"zerto": {
"vpgName": "ubuntu-2404-local",
"internalVpgName": "ubuntu-2404-local",
"operation": "Test",
"force": "Yes",
"vmDisplayNames": "ubuntu-2404(1)(1)(1)",
"hypervisorManagerIP": "192.168.50.20",
"hypervisorManagerPort": "443",
"outputDir": "/app/scripts-output",
"workingDir": "/app/scripts-files"
}
}
```
A webhook outage **does not fail the VPG** — the script catches and exits 0.
Comment in the file shows how to flip that to strict mode if you'd rather a
webhook outage abort the failover.
## 2. The webhook-server-side scripts (receivers)
Two examples ship in the repo. Both read the JSON body from stdin (the
webhook server delivers the body to the script's stdin when **JSON body to
stdin** is ticked on the endpoint).
### a. Slack/Teams notification — both phases
[`scripts/examples/zerto-receiver-notify.ps1`](../../scripts/examples/zerto-receiver-notify.ps1)
posts a single-line summary to a Slack or Teams Incoming Webhook URL. It
picks an icon based on `ZertoOperation`:
- `Test` → 🧪 — benign, expected
- `Failover` → 🚨 — real production event
- `Move` → 🚚 — planned migration
…and highlights `ZertoForce=Yes` on the **pre** message so you can see at
a glance whether the operation was force-flagged.
Set the destination via `NOTIFY_URL` env var on the webhook host, or
hardcode at the top of the script.
### b. Post-recovery VM health check — post phase only
[`scripts/examples/zerto-receiver-vm-healthcheck.ps1`](../../scripts/examples/zerto-receiver-vm-healthcheck.ps1)
runs only on `phase=post` for operations that bring VMs up
(`Test`/`Failover`/`Move`/`FailoverBeforeCommit`/`FailoverDuringCommit`).
For each name in `VmDisplayNames` it:
1. Strips the trailing `(1)(1)(1)` suffix Zerto adds on Test failovers, so
DNS resolution targets the actual hostname.
2. Pings (`Test-Connection`).
3. Probes a configurable TCP port (`-ProbePort`, default `3389` for RDP;
use `22` for SSH or `443` for the web tier).
4. Writes a JSON report to
`C:\ProgramData\WebhookServer\zerto-healthchecks\<vpg>-<op>-<utcstamp>.json`.
5. Exits non-zero if any VM failed either probe — which surfaces in the
webhook server's run history (and outbound callback, if configured).
Bump the endpoint's **Timeout (sec)** to `120` when wiring this in, since
network probes can take a while.
## 3. Configure the endpoints in the GUI
Two endpoints. Identical except for the slug, the script, and (for the
healthcheck) the timeout.
### `zerto-pre`
| Section | Setting | Value |
|---|---|---|
| Identity | Slug | `zerto-pre` |
| Identity | Description | "Zerto pre-recovery: chat notification" |
| Auth | Mode | **Bearer** |
| Auth | Bearer secret | generate a 32-byte random string; reuse for `zerto-post` |
| Allowed clients | (one per line) | the IP of the K8s node running `scripts-service` (e.g. `192.168.50.30`) |
| Executor | Type | **Windows PowerShell** (or PowerShell 7) |
| Executor | Script path | `C:\scripts\zerto-receiver-notify.ps1` |
| Data passing | JSON body to stdin | ✓ |
| Run as | Identity | **Service** |
| Response | Mode | **Async** |
| Response | Timeout (sec) | `30` |
| Response | Fail on non-zero exit | unticked *(async hooks have no caller to receive a 502)* |
### `zerto-post`
Same as above, except:
| Setting | Value |
|---|---|
| Slug | `zerto-post` |
| Description | "Zerto post-recovery: notify + VM health check" |
| Script path | a **wrapper** that calls both receiver scripts in turn (see below) |
| Timeout (sec) | `120` |
Two receivers on one endpoint is easiest with a tiny wrapper that fans
stdin out to both scripts:
```powershell
# C:\scripts\zerto-post-fanout.ps1
$body = [Console]::In.ReadToEnd()
$body | & 'C:\scripts\zerto-receiver-notify.ps1'
$body | & 'C:\scripts\zerto-receiver-vm-healthcheck.ps1'
```
Or run the two as separate endpoints (`zerto-post-notify` and
`zerto-post-healthcheck`) and have the Zerto-side script POST to both —
either pattern is fine. The fanout wrapper keeps the Zerto config simpler.
## 4. Wire up the bearer token
On the ZVMA / scripts-service side, the easiest place to put the token is
a Kubernetes Secret mounted into the pod, but the simplest approach for
testing is to pass it as a parameter to the Zerto-side script:
> VPG settings → Pre-Recovery Script → Parameters:
> `-Phase pre -Bearer <paste-token>`
>
> VPG settings → Post-Recovery Script → Parameters:
> `-Phase post -Bearer <paste-token>`
For production, mount a Secret at a known path in the pod and have the
sender script read from it (`Get-Content /run/secrets/webhook-token`).
## 5. Test before going live
Run a Test failover on a non-critical VPG. Watch:
- **Slack/Teams**: a `:test_tube: Zerto Test - phase: pre` message arrives,
followed ~30sseveral minutes later by a `:test_tube: Zerto Test - phase:
post` message.
- **Webhook Server GUI** → run history: two runs for `zerto-pre` /
`zerto-post`, both green.
- **`C:\ProgramData\WebhookServer\zerto-healthchecks\`**: a fresh JSON
report named `<vpg>-Test-<utcstamp>.json` containing per-VM ping and port
probe results.
- **ZVMA**: the VPG operation completes successfully; nothing in the
pre/post logs blocked on the webhook.
## Variations
### Branch on Test vs. real failover in the receivers
The notifier already styles the message differently. To do something only
on a real failover (e.g. update DNS), guard with:
```powershell
if ($p.zerto.operation -ne 'Test') {
# do the destructive thing
}
```
A `ZertoOperation` of `Test` means "exercise — don't touch production
dependencies." Always check it before doing anything that mutates real
state.
### Capture `ZertoForce` from pre for use in post
`ZertoForce` is `Yes` only during the **pre** phase when force mode is on
and is reset to `No` by the **post** phase. If your post-side logic needs
to know the operation was force-flagged, save it during pre (e.g. write a
small marker to the shared `ZertoOutputDir`) and read it back during post.
### Per-VPG endpoints
For fine-grained access control or different actions per VPG, create one
endpoint per VPG (`zerto-pre-app01`, `zerto-post-app01`, …) with its own
bearer token. Override `-WebhookUrl` and `-Bearer` on the Zerto side per
VPG.
### Audit trail
Every endpoint can have an outbound **Callback** URL. Configure with your
SIEM's HTTP collector + an HMAC secret, and every run produces a JSON
record with runId, exit code, duration, stdout, and stderr — convenient
for compliance.
## Security note
The ZVMA `scripts-service` pod runs your scripts inside a Linux container
with broad reach into the management cluster — anything your script does
runs with whatever ServiceAccount that pod uses. Treat the script content
as privileged and make sure pre/post script edit rights are restricted to
trusted operators. If you're unfamiliar with the pod's RBAC posture, check
`Get-ChildItem Env:` from inside the container and look at
`/var/run/secrets/kubernetes.io/serviceaccount/` — that token is what your
scripts (and a malicious script) can use to talk to the K8s API.
+7 -1
View File
@@ -55,6 +55,7 @@ Source: "{#RepoRoot}publish\service\*"; DestDir: "{app}"; Flags: ignoreversion r
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
@@ -69,9 +70,14 @@ 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 skipifsilent
Flags: postinstall nowait shellexec skipifsilent
[UninstallRun]
Filename: "powershell.exe"; \
+138 -3
View File
@@ -53,15 +53,150 @@ if ($LASTEXITCODE -ne 0) { throw 'service publish failed' }
-c $Configuration -r win-x64 --self-contained false -o $publishGui | Out-Host
if ($LASTEXITCODE -ne 0) { throw 'GUI publish failed' }
# 2. Compile installer.
# 2. Pre-flight: confirm every source path the .iss references exists, and
# surface the longest path so MAX_PATH issues are obvious in the log.
function Show-SourcePath($label, $path, [switch]$Recursive) {
if (-not (Test-Path $path)) { Write-Warning "MISSING $label : $path"; return }
$items = if ($Recursive) {
Get-ChildItem $path -Recurse -File -ErrorAction SilentlyContinue
} else {
Get-ChildItem $path -File -ErrorAction SilentlyContinue
}
$count = ($items | Measure-Object).Count
$longest = ($items | Measure-Object -Maximum -Property { $_.FullName.Length }).Maximum
Write-Host (" {0,-30} files={1,-5} longestPath={2,-5} root={3}" -f $label, $count, $longest, $path)
}
Write-Host ""
Write-Host "--- pre-flight: source paths the .iss will read ---" -ForegroundColor Cyan
Show-SourcePath 'publish\service' $publishSvc -Recursive
Show-SourcePath 'publish\gui' $publishGui -Recursive
Show-SourcePath 'scripts' (Join-Path $repoRoot 'scripts')
Show-SourcePath 'scripts\examples' (Join-Path $repoRoot 'scripts\examples') -Recursive
Show-SourcePath 'docs' (Join-Path $repoRoot 'docs') -Recursive
Show-SourcePath 'resources' (Join-Path $repoRoot 'resources')
Show-SourcePath 'README.md (file)' (Join-Path $repoRoot 'README.md')
$lpe = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem' `
-Name LongPathsEnabled -ErrorAction SilentlyContinue).LongPathsEnabled
Write-Host " LongPathsEnabled (HKLM): $lpe"
Write-Host ""
# 3. 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' }
# Run ISCC from the .iss directory with just the bare filename. When invoked
# with a deeply-nested absolute path on the act-runner host (under
# %SystemRoot%\System32\config\systemprofile\...), ISCC sometimes prints a
# generic "The system cannot find the path specified." before it touches any
# source files. cd-ing first sidesteps it.
$issDir = Split-Path $iss -Parent
$issName = Split-Path $iss -Leaf
# Extra pre-flight: confirm the specific files our .iss references that a
# trivial test .iss wouldn't (icon, README, scripts) actually exist relative
# to the .iss directory the way ISCC will resolve them (RepoRoot = ..\).
Write-Host "--- pre-flight: paths the .iss references via {#RepoRoot} ---" -ForegroundColor Cyan
$issRefs = @(
'resources\webhook-server.ico',
'README.md',
'scripts\install-service.ps1',
'scripts\uninstall-service.ps1',
'publish\service',
'publish\gui',
'docs',
'scripts\examples'
)
foreach ($ref in $issRefs) {
$abs = Join-Path $repoRoot $ref
$exists = Test-Path $abs
Write-Host (" {0,-40} exists={1} ({2})" -f $ref, $exists, $abs)
}
Write-Host ""
Write-Host "--- runtime context ---" -ForegroundColor Cyan
Write-Host " identity: $([Security.Principal.WindowsIdentity]::GetCurrent().Name)"
Write-Host " USERPROFILE: $env:USERPROFILE"
Write-Host " APPDATA: $env:APPDATA"
Write-Host " LOCALAPPDATA: $env:LOCALAPPDATA"
Write-Host " TEMP: $env:TEMP"
$isccDir = Split-Path $iscc -Parent
Write-Host " ISCC dir: $isccDir"
foreach ($f in @('ISCC.exe','ISCmplr.dll','ISPP.dll','Default.isl','Compil32.exe')) {
$p = Join-Path $isccDir $f
Write-Host (" {0,-15} exists={1}" -f $f, (Test-Path $p))
}
Write-Host ""
Write-Host " PS location (pre): $((Get-Location).Path)"
Write-Host " .NET cwd (pre): $([System.IO.Directory]::GetCurrentDirectory())"
Push-Location $issDir
$savedDotNetCwd = [System.IO.Directory]::GetCurrentDirectory()
[System.IO.Directory]::SetCurrentDirectory($issDir)
try {
Write-Host " PS location (post): $((Get-Location).Path)"
Write-Host " .NET cwd (post): $([System.IO.Directory]::GetCurrentDirectory())"
# Sanity: compile a minimal .iss right next to ours BEFORE attempting the
# real one. Minimal has no #defines, no [Code], no [Files], no compression
# tweak - just the absolute floor of what ISCC will accept. If THIS fails
# under the same SYSTEM context with the same identical exit/error, the
# problem is environmental, not in our .iss content.
$minIss = Join-Path $issDir "min-test.iss"
@"
[Setup]
AppName=MinTest
AppVersion=1.0
AppId={{12345678-1234-1234-1234-123456789ABC}
DefaultDirName={pf}\MinTest
CreateAppDir=no
Uninstallable=no
OutputBaseFilename=mintest
OutputDir=$dist
"@ | Set-Content -Path $minIss -Encoding ascii
Write-Host ""
Write-Host "--- bisect step 1: minimal .iss ---" -ForegroundColor Cyan
& $iscc (Split-Path $minIss -Leaf) *>&1 | ForEach-Object { Write-Host " $_" }
$minExit = $LASTEXITCODE
Write-Host " minimal exit: $minExit"
Remove-Item $minIss -ErrorAction SilentlyContinue
Write-Host ""
# Bake the version into a temp .iss and override OutputDir to an absolute
# path so nothing in the build depends on cwd resolution.
$tempIss = Join-Path $issDir "webhook-server.gen.iss"
$issBody = Get-Content $issName -Raw
$pattern = '(?s)#ifndef AppVersion\s+#define AppVersion "[^"]*"\s+#endif'
if ($issBody -notmatch $pattern) { throw "Could not find #ifndef AppVersion block in $issName" }
$issBody = $issBody -replace $pattern, "#define AppVersion `"$version`""
Set-Content -Path $tempIss -Value $issBody -Encoding ascii
Write-Host " using $tempIss"
# Capture stdout+stderr together so any error line ISCC emits is visible
# in the runner log even if the runner's console capture drops one stream.
# /O<absolute> overrides OutputDir so ..\dist isn't resolved relative to
# whatever cwd ISCC actually inherits.
$logPath = Join-Path $env:TEMP "iscc-$version.log"
& $iscc "/O$dist" (Split-Path $tempIss -Leaf) *>&1 | Tee-Object -FilePath $logPath | ForEach-Object { Write-Host $_ }
$exit = $LASTEXITCODE
Write-Host " ISCC exit code: $exit"
Write-Host " ISCC log path: $logPath"
if (Test-Path $logPath) {
Write-Host " --- iscc log file contents ---"
Get-Content $logPath | ForEach-Object { Write-Host " $_" }
Write-Host " --- end iscc log ---"
}
Remove-Item $tempIss -ErrorAction SilentlyContinue
} finally {
[System.IO.Directory]::SetCurrentDirectory($savedDotNetCwd)
Pop-Location
}
if ($exit -ne 0) { throw "Inno Setup compile failed (exit $exit)" }
$out = Get-Item (Join-Path $dist "WebhookServer-Setup-$version.exe")
Write-Host ""
+46
View File
@@ -0,0 +1,46 @@
<#
.SYNOPSIS
Server-side receiver for the env-dump webhook. Reads the JSON body from
stdin and writes it to a timestamped file on disk.
.DESCRIPTION
Configure a webhook endpoint like this:
Executable: powershell.exe (or pwsh.exe)
Arguments: -NoProfile -ExecutionPolicy Bypass -File C:\path\to\save-env-vars.ps1
Data passing: [x] Stdin JSON
Run As: Service (or any account that can write to $OutDir)
Output goes to C:\ProgramData\WebhookServer\env-dumps\<host>-<utcstamp>.json
by default; override with -OutDir.
#>
[CmdletBinding()]
param(
[string] $OutDir = 'C:\ProgramData\WebhookServer\env-dumps'
)
$ErrorActionPreference = 'Stop'
if (-not (Test-Path $OutDir)) {
New-Item -ItemType Directory -Path $OutDir -Force | Out-Null
}
$body = [Console]::In.ReadToEnd()
if ([string]::IsNullOrWhiteSpace($body)) {
Write-Error 'Empty request body on stdin.'
exit 2
}
# Parse so we can pull the host name for the filename, and to fail fast on
# malformed JSON before writing it.
$parsed = $body | ConvertFrom-Json
$hostName = if ($parsed.host) { $parsed.host } else { 'unknown' }
$safeHost = ($hostName -replace '[^A-Za-z0-9_.-]', '_')
$stamp = (Get-Date).ToUniversalTime().ToString('yyyyMMddTHHmmssZ')
$path = Join-Path $OutDir "$safeHost-$stamp.json"
# Persist the original body verbatim - keeps key ordering and avoids any
# round-trip surprises from ConvertTo-Json.
Set-Content -Path $path -Value $body -Encoding utf8
Write-Host "Saved $($body.Length) bytes to $path"
+68
View File
@@ -0,0 +1,68 @@
<#
.SYNOPSIS
Collects env vars from PowerShell and bash, packages them into a single
JSON object, and POSTs the result to a Webhook Server endpoint.
.DESCRIPTION
Output JSON shape:
{
"host": "<computername>",
"capturedAt":"2026-05-08T12:34:56Z",
"pwsh": { "VAR": "value", ... },
"bash": { "VAR": "value", ... }
}
Pair this with `save-env-vars.ps1` on the server side - configure an
endpoint with StdinJson enabled and that script as the executable.
#>
[CmdletBinding()]
param(
[string] $WebhookUrl = 'http://localhost:8080/hook/env-dump',
[string] $Bearer = '',
[string] $BashExe = 'bash'
)
$ErrorActionPreference = 'Stop'
# --- pwsh env vars --------------------------------------------------------
$pwshVars = [ordered]@{}
Get-ChildItem Env: | Sort-Object Name | ForEach-Object {
$pwshVars[$_.Name] = $_.Value
}
# --- bash env vars --------------------------------------------------------
$bashVars = [ordered]@{}
$bashCmd = Get-Command $BashExe -ErrorAction SilentlyContinue
if ($null -ne $bashCmd) {
# `env -0` separates entries with NUL so values containing newlines stay intact.
$raw = & $bashCmd.Source -c 'env -0' 2>$null
if ($LASTEXITCODE -eq 0 -and $raw) {
foreach ($entry in ($raw -split "`0")) {
if ([string]::IsNullOrEmpty($entry)) { continue }
$eq = $entry.IndexOf('=')
if ($eq -lt 1) { continue }
$bashVars[$entry.Substring(0, $eq)] = $entry.Substring($eq + 1)
}
}
} else {
Write-Warning "bash not found on PATH (looked for '$BashExe'); 'bash' section will be empty."
}
# --- assemble payload -----------------------------------------------------
$payload = [ordered]@{
host = $env:COMPUTERNAME
capturedAt = (Get-Date).ToUniversalTime().ToString('o')
pwsh = $pwshVars
bash = $bashVars
}
$json = $payload | ConvertTo-Json -Depth 5 -Compress
# --- POST -----------------------------------------------------------------
$headers = @{ 'Content-Type' = 'application/json' }
if ($Bearer) { $headers['Authorization'] = "Bearer $Bearer" }
Write-Host "POST $WebhookUrl ($($json.Length) bytes; pwsh=$($pwshVars.Count), bash=$($bashVars.Count))"
$response = Invoke-RestMethod -Method Post -Uri $WebhookUrl -Headers $headers -Body $json
$response | ConvertTo-Json -Depth 5
+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
}
@@ -0,0 +1,90 @@
<#
.SYNOPSIS
Webhook-server-side receiver: posts a Slack/Teams notification when a VPG
fires its pre or post recovery script.
.DESCRIPTION
Reads the JSON body from stdin (the payload sent by zerto-zvma-send.ps1),
builds a phase-aware message, and posts it to an Incoming Webhook URL.
The message highlights:
- VPG name + operation type (Test / Failover / Move / ...)
- Whether ZertoForce was set (only relevant pre)
- VM display names included in the run
- Phase (pre vs post) so you can see the bracketing in chat
Wire up two endpoints:
/hook/zerto-pre -> this script with -Phase pre (pass via args)
/hook/zerto-post -> this script with -Phase post
Or one endpoint per phase, each pointing at this script. The script reads
`phase` from the JSON body, so the -Phase param is optional.
.NOTES
Compatible with:
- Slack Incoming Webhooks (posts {"text": "..."})
- Teams legacy connector "Incoming Webhook" (same body shape)
- Discord webhooks (use ?wait=true for body, but text is "content" not
"text" - tweak below)
Endpoint config:
ExecutorType: WindowsPowerShell or PowerShell 7
ScriptPath: C:\scripts\zerto-receiver-notify.ps1
DataPassing: [x] Stdin JSON
ResponseMode: async (we don't need to block the VPG on a chat post)
#>
[CmdletBinding()]
param(
[string] $NotifyUrl = $env:NOTIFY_URL # set on the Webhook Server host, or hardcode below
)
$ErrorActionPreference = 'Stop'
if (-not $NotifyUrl) {
# Fall back to a hardcoded URL if NOTIFY_URL env var isn't set.
# Replace with your Slack/Teams Incoming Webhook URL.
$NotifyUrl = 'https://hooks.slack.com/services/REPLACE/ME/HERE'
}
$body = [Console]::In.ReadToEnd()
if ([string]::IsNullOrWhiteSpace($body)) {
Write-Error 'Empty stdin - expected JSON body from the webhook server.'
exit 2
}
$p = $body | ConvertFrom-Json
$z = $p.zerto
$phase = if ($p.phase) { $p.phase } else { 'unknown' }
$op = if ($z.operation) { $z.operation } else { 'unknown' }
# Pick an icon based on operation. Test is benign; Failover/Move are real.
$icon = switch ($op) {
'Test' { ':test_tube:' }
'Failover' { ':rotating_light:' }
'Move' { ':truck:' }
default { ':information_source:' }
}
$forceTag = if ($phase -eq 'pre' -and $z.force -eq 'Yes') { ' *(FORCE)*' } else { '' }
$lines = @(
"$icon *Zerto $op* - phase: ``$phase``$forceTag"
"VPG: ``$($z.vpgName)``"
"VMs: ``$($z.vmDisplayNames)``"
"Hypervisor mgr: ``$($z.hypervisorManagerIP):$($z.hypervisorManagerPort)``"
"Captured: $($p.capturedAt) (from $($p.host))"
)
$text = $lines -join "`n"
$payload = @{ text = $text } | ConvertTo-Json -Compress
try {
Invoke-RestMethod -Method Post -Uri $NotifyUrl `
-ContentType 'application/json' -Body $payload -TimeoutSec 10 | Out-Null
Write-Host "[$phase] notified $op for VPG '$($z.vpgName)'"
}
catch {
Write-Error "Notification post failed: $($_.Exception.Message)"
exit 1
}
@@ -0,0 +1,140 @@
<#
.SYNOPSIS
Webhook-server-side receiver: post-failover VM health check. Pings each
VM in the VPG and probes a configurable TCP port; writes a per-run
report to disk.
.DESCRIPTION
Intended for the POST-recovery webhook only - on a Test or real Failover,
once the VMs are powered on at the recovery site, we can spot-check that
they responded to ICMP and that a known port is listening (RDP, SSH,
HTTP, etc).
Skips itself entirely on the pre-recovery phase (nothing's running yet)
and on $z.operation values that don't bring VMs up.
Wire up one endpoint:
/hook/zerto-post -> this script
DataPassing: [x] Stdin JSON
ResponseMode: async
.NOTES
VmDisplayNames is a comma-separated list for multi-VM VPGs; some Zerto
versions wrap each name in parentheses (e.g. "vm1(1)(1)(1)") to disambig
after Test failover. We strip the trailing parenthesised suffixes when
resolving DNS so the recovered hostname is what we ping.
Endpoint config:
ExecutorType: WindowsPowerShell or PowerShell 7
ScriptPath: C:\scripts\zerto-receiver-vm-healthcheck.ps1
DataPassing: [x] Stdin JSON
ResponseMode: async
TimeoutSeconds: 120 (this script does network I/O - bump from default)
#>
[CmdletBinding()]
param(
[int] $ProbePort = 3389, # RDP. Use 22 for Linux, 80/443 for web tier.
[int] $PingTimeout = 2000, # ms
[string] $ReportDir = 'C:\ProgramData\WebhookServer\zerto-healthchecks'
)
$ErrorActionPreference = 'Stop'
# --- read + parse payload -------------------------------------------------
$body = [Console]::In.ReadToEnd()
if ([string]::IsNullOrWhiteSpace($body)) {
Write-Error 'Empty stdin.'
exit 2
}
$p = $body | ConvertFrom-Json
$z = $p.zerto
$phase = $p.phase
$op = $z.operation
# Skip if this isn't a post-phase run for an op that powers VMs on.
if ($phase -ne 'post') {
Write-Host "Phase '$phase' - nothing to check yet, skipping."
exit 0
}
if ($op -notin @('Test','Failover','Move','FailoverBeforeCommit','FailoverDuringCommit')) {
Write-Host "Operation '$op' doesn't bring VMs up; skipping."
exit 0
}
# --- parse VM list --------------------------------------------------------
function Strip-ZertoSuffix {
param([string] $name)
# "ubuntu-2404(1)(1)(1)" -> "ubuntu-2404"
return ($name -replace '(\([^)]*\))+\s*$','').Trim()
}
$rawNames = ($z.vmDisplayNames -split '[,;]') | ForEach-Object { $_.Trim() } |
Where-Object { $_ }
if (-not $rawNames) {
Write-Warning 'No VM display names in payload - nothing to check.'
exit 0
}
# --- run checks -----------------------------------------------------------
$results = foreach ($raw in $rawNames) {
$clean = Strip-ZertoSuffix $raw
$pingOk = $false
$portOk = $false
$err = $null
try {
$pingOk = (Test-Connection -ComputerName $clean -Count 1 -Quiet `
-TimeoutSeconds ([math]::Max(1, [int]($PingTimeout / 1000))) `
-ErrorAction Stop)
} catch { $err = "ping: $($_.Exception.Message)" }
try {
$portOk = (Test-NetConnection -ComputerName $clean -Port $ProbePort `
-InformationLevel Quiet -WarningAction SilentlyContinue)
} catch { $err = ($err, "port: $($_.Exception.Message)") -ne $null -join '; ' }
[pscustomobject]@{
DisplayName = $raw
Resolved = $clean
PingOk = $pingOk
PortOk = $portOk
ProbePort = $ProbePort
Error = $err
}
}
# --- write report ---------------------------------------------------------
if (-not (Test-Path $ReportDir)) {
New-Item -ItemType Directory -Path $ReportDir -Force | Out-Null
}
$safeVpg = ($z.vpgName -replace '[^A-Za-z0-9_.-]','_')
$stamp = (Get-Date).ToUniversalTime().ToString('yyyyMMddTHHmmssZ')
$file = Join-Path $ReportDir "$safeVpg-$op-$stamp.json"
$report = [ordered]@{
vpgName = $z.vpgName
operation = $op
phase = $phase
capturedAt = $p.capturedAt
completedAt = (Get-Date).ToUniversalTime().ToString('o')
probePort = $ProbePort
vms = $results
summary = @{
total = $results.Count
pingFailures = ($results | Where-Object { -not $_.PingOk }).Count
portFailures = ($results | Where-Object { -not $_.PortOk }).Count
}
}
$report | ConvertTo-Json -Depth 5 | Set-Content -Path $file -Encoding utf8
# Console output goes back via the webhook callback (if configured) so the
# Zerto-side script log shows a quick summary even though the call is async.
$bad = $report.summary.pingFailures + $report.summary.portFailures
Write-Host "[$op/$phase] $($z.vpgName): $($results.Count) VM(s), $bad issue(s). Report: $file"
# Exit non-zero if anything failed, so the webhook server's failOnNonZeroExit
# turns this into a 502 for the caller (and shows up in the run history).
if ($bad -gt 0) { exit 1 }
+74
View File
@@ -0,0 +1,74 @@
<#
.SYNOPSIS
Zerto pre/post script (ZVMA / Linux scripts-service edition). Reads the
Zerto-injected environment variables and POSTs them to a Webhook Server
endpoint as a structured JSON payload.
.DESCRIPTION
Drop into a VPG's Recovery Scripts in the ZVM UI:
VPG settings -> Recovery -> Scripts -> Pre / Post Recovery Script
Path: /app/scripts-files/zerto-zvma-send.ps1
Parameters: -Phase pre (or -Phase post on the post-recovery slot)
Configure $WebhookUrl + $Bearer (or use the -WebhookUrl / -Bearer params
so one script file can serve multiple VPGs / endpoints).
Async by default - the call returns 202 in milliseconds and the actual
work runs in the webhook server's background, so the VPG sequence is
never blocked by slow downstream actions (DNS, notifications, etc.).
.NOTES
The scripts-service container has pwsh 7 and curl available. This script
uses Invoke-RestMethod to keep things native to PowerShell.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[ValidateSet('pre', 'post')]
[string] $Phase,
[string] $WebhookUrl = 'http://192.168.50.250:8080/hook/zerto-{phase}',
[string] $Bearer = '',
[int] $TimeoutSec = 10
)
$ErrorActionPreference = 'Stop'
# Resolve {phase} placeholder so one URL template can route to /hook/zerto-pre
# and /hook/zerto-post. Plain URLs without the token work too.
$url = $WebhookUrl.Replace('{phase}', $Phase)
$payload = [ordered]@{
phase = $Phase
capturedAt = (Get-Date).ToUniversalTime().ToString('o')
host = $env:HOSTNAME # scripts-service pod name
zerto = [ordered]@{
vpgName = $env:ZertoVPGName
internalVpgName = $env:ZertoInternalVpgName
operation = $env:ZertoOperation # Test / Failover / Move / ...
force = $env:ZertoForce # only meaningful pre
vmDisplayNames = $env:VmDisplayNames # comma-separated for multi-VM VPGs
hypervisorManagerIP = $env:ZertoHypervisorManagerIP
hypervisorManagerPort = $env:ZertoHypervisorManagerPort
outputDir = $env:ZertoOutputDir
workingDir = $env:ZertoWorkingDir
}
}
$body = $payload | ConvertTo-Json -Depth 4 -Compress
$headers = @{ 'Content-Type' = 'application/json' }
if ($Bearer) { $headers['Authorization'] = "Bearer $Bearer" }
try {
$resp = Invoke-RestMethod -Method Post -Uri $url -Headers $headers `
-Body $body -TimeoutSec $TimeoutSec
Write-Host "[$Phase] webhook accepted: $($resp | ConvertTo-Json -Compress)"
}
catch {
# Pre/post failures should not block the VPG operation. Log loudly and exit 0
# so Zerto's recovery sequence continues. Flip to `exit 1` if you want a
# webhook outage to fail the failover.
Write-Warning "[$Phase] webhook call failed: $($_.Exception.Message)"
}
+159
View File
@@ -0,0 +1,159 @@
<#
.SYNOPSIS
Mirrors the in-repo docs/ folder to a GitHub or Gitea wiki repo.
.DESCRIPTION
Wikis are separate git repositories (e.g. <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'
)
# Continue (not Stop) because git writes informational messages to stderr
# (CRLF warnings, "remote: Processed N references" etc.) which PowerShell 5.1
# escalates to a script-fatal error under Stop. We check $LASTEXITCODE
# manually after each git call instead.
$ErrorActionPreference = 'Continue'
$repoRoot = Split-Path -Parent $PSScriptRoot
$docsDir = Join-Path $repoRoot 'docs'
$workDir = Join-Path $env:TEMP ("webhook-wiki-{0}" -f ([guid]::NewGuid().ToString('N').Substring(0, 8)))
# Source path (relative to docs/) -> wiki page slug. Order matters for the sidebar.
$mapping = [ordered]@{}
$mapping.Add('README.md', 'Home')
$mapping.Add('concepts.md', 'Concepts')
$mapping.Add('installation.md', 'Installation')
$mapping.Add('upgrading.md', 'Upgrading')
$mapping.Add('uninstalling.md', 'Uninstalling')
$mapping.Add('runas-modes.md', 'Run-As-Modes')
$mapping.Add('service-account-and-ad.md', 'Service-Account-and-AD')
$mapping.Add('network-and-security.md', 'Network-and-Security')
$mapping.Add('troubleshooting.md', 'Troubleshooting')
$mapping.Add('recipes/zerto-pre-post-scripts.md', 'Recipe-Zerto-Failover')
$mapping.Add('recipes/github-style-hmac.md', 'Recipe-GitHub-HMAC')
$mapping.Add('recipes/ui-on-desktop.md', 'Recipe-UI-on-Desktop')
function Rewrite-Links([string]$content) {
foreach ($m in $mapping.GetEnumerator()) {
# Match (path/to/file.md) and (path/to/file.md#anchor) inside markdown
# link parens. The lookbehind ensures we're consuming a real link target.
$escaped = [regex]::Escape($m.Key)
$content = [regex]::Replace($content,
"\(\.?\.?/?$escaped(\#[^)\s]*)?\)",
"($($m.Value)`$1)")
}
# Also clean up doubled prefixes like "../../docs/" or "../" pointers that
# sometimes appear in cross-folder relative links from docs/recipes/.
return $content
}
function New-Sidebar() {
$lines = @()
$lines += "[Home](Home)"
$lines += ""
$lines += "## Topical"
foreach ($key in @('concepts.md','installation.md','upgrading.md','uninstalling.md','runas-modes.md','service-account-and-ad.md','network-and-security.md','troubleshooting.md')) {
$slug = $mapping[$key]
$lines += "- [$($slug -replace '-', ' ')]($slug)"
}
$lines += ""
$lines += "## Recipes"
foreach ($key in @('recipes/zerto-pre-post-scripts.md','recipes/github-style-hmac.md','recipes/ui-on-desktop.md')) {
$slug = $mapping[$key]
$lines += "- [$($slug -replace '^Recipe-' -replace '-', ' ')]($slug)"
}
return ($lines -join "`n")
}
# 1. Clone the wiki.
Write-Host "Cloning wiki to $workDir..."
& git clone --quiet $WikiUrl $workDir 2>&1 | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "git clone failed. Has the wiki been initialized? Visit the repo's Wiki tab and create the first page via the UI before running this script."
}
# Suppress git's CRLF nags for this throwaway clone so they don't become
# "errors" via PowerShell's native-command stderr handling.
& git -C $workDir config core.autocrlf false 2>&1 | Out-Null
& git -C $workDir config core.safecrlf false 2>&1 | Out-Null
try {
Push-Location $workDir
try {
# 2. Wipe existing markdown so removed source files vanish from the wiki.
Get-ChildItem -Filter "*.md" -Force | Remove-Item -Force
# 3. Copy + transform each source file.
$written = 0
foreach ($entry in $mapping.GetEnumerator()) {
$src = Join-Path $docsDir $entry.Key
$dst = Join-Path $workDir "$($entry.Value).md"
if (-not (Test-Path $src)) {
Write-Warning "Source missing, skipping: $src"
continue
}
$content = Get-Content -LiteralPath $src -Raw
$content = Rewrite-Links $content
Set-Content -LiteralPath $dst -Value $content -Encoding utf8 -NoNewline
$written++
}
Write-Host "Wrote $written markdown pages."
# 4. Sidebar
Set-Content -LiteralPath (Join-Path $workDir '_Sidebar.md') -Value (New-Sidebar) -Encoding utf8 -NoNewline
# 5. Commit + push if anything actually changed. Drain stderr from each
# git invocation so PowerShell doesn't treat warnings as errors.
& git add -A 2>&1 | Out-Null
$changes = & git status --porcelain 2>&1
if (-not $changes) {
Write-Host "Wiki already up to date."
return
}
$sha = & git -C $repoRoot rev-parse --short HEAD 2>&1
& git -c "user.name=$AuthorName" -c "user.email=$AuthorEmail" commit -q -m "Sync from docs/ at $sha" 2>&1 | Out-Null
if ($LASTEXITCODE -ne 0) { throw "git commit failed (exit $LASTEXITCODE)" }
& git push --quiet 2>&1 | Out-Null
if ($LASTEXITCODE -ne 0) { throw "git push failed (exit $LASTEXITCODE)" }
Write-Host "Pushed updated wiki."
}
finally { Pop-Location }
}
finally {
Remove-Item -Recurse -Force $workDir -ErrorAction SilentlyContinue
}
@@ -26,6 +26,7 @@ public static class AdminOps
public const string ListBackups = "list-backups";
public const string RestoreBackup = "restore-backup";
public const string ImportConfig = "import-config";
public const string CreateCheckpoint = "create-checkpoint";
}
public sealed class BackupEntry
@@ -33,6 +34,7 @@ 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
@@ -40,6 +42,11 @@ public sealed class RestoreBackupArgs
public string FileName { get; set; } = "";
}
public sealed class CreateCheckpointArgs
{
public string? Description { get; set; }
}
public sealed class AdminRequest
{
[JsonPropertyName("op")] public string Op { get; set; } = "";
+16 -2
View File
@@ -48,8 +48,15 @@ public sealed class ConfigStore
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);
PruneBackups(backupsDir, retain: 30);
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
{
@@ -71,11 +78,18 @@ public sealed class ConfigStore
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(); } catch { }
try
{
f.Delete();
var sidecar = System.IO.Path.ChangeExtension(f.FullName, ".meta.json");
if (File.Exists(sidecar)) File.Delete(sidecar);
}
catch { }
}
}
+20 -24
View File
@@ -29,25 +29,12 @@
<Separator/>
<MenuItem Header="_Import config…" Command="{Binding ImportConfigCommand}"/>
<MenuItem Header="_Export config…" Command="{Binding ExportConfigCommand}"/>
<MenuItem Header="Config _Checkpoints"
ItemsSource="{Binding Backups}"
ToolTip="Snapshots taken automatically before each save. Click one to restore."
SubmenuOpened="OnBackupsSubmenuOpened">
<MenuItem.ItemContainerStyle>
<Style TargetType="MenuItem">
<Setter Property="Header">
<Setter.Value>
<MultiBinding StringFormat="{}{0:yyyy-MM-dd HH:mm:ss} ({1:n0} bytes)">
<Binding Path="SavedAt"/>
<Binding Path="SizeBytes"/>
</MultiBinding>
</Setter.Value>
</Setter>
<Setter Property="Command" Value="{Binding DataContext.RestoreBackupCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
<Setter Property="CommandParameter" Value="{Binding}"/>
</Style>
</MenuItem.ItemContainerStyle>
</MenuItem>
<MenuItem Header="Config _Checkpoints…" Command="{Binding ShowConfigCheckpointsCommand}"/>
<Separator/>
<MenuItem Header="_Minimize to tray"
IsCheckable="True"
IsChecked="{Binding MinimizeToTrayEnabled, Mode=TwoWay}"
ToolTip="When ticked, closing or minimizing the window hides it to the tray and keeps the GUI process alive. Untick to make the X button quit the app."/>
<Separator/>
<MenuItem Header="E_xit" Command="{Binding ExitCommand}"/>
</MenuItem>
@@ -81,17 +68,26 @@
<DataGrid.RowStyle>
<Style TargetType="DataGridRow">
<EventSetter Event="MouseDoubleClick" Handler="OnRowDoubleClick"/>
<!-- The ContextMenu lives in its own visual tree (a popup), so
AncestorType=Window doesn't resolve from inside menu items.
Stash MainViewModel on the row's Tag here (still in the
Window's tree), then reach it from the menu via
PlacementTarget.Tag. -->
<Setter Property="Tag" Value="{Binding DataContext, RelativeSource={RelativeSource AncestorType=Window}}"/>
<Setter Property="ContextMenu">
<Setter.Value>
<ContextMenu>
<MenuItem Header="_Edit…" Command="{Binding DataContext.EditEndpointCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
<MenuItem Header="_Copy URL" Command="{Binding DataContext.CopyEndpointUrlCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
<MenuItem Header="_Edit…"
Command="{Binding PlacementTarget.Tag.EditEndpointCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"/>
<MenuItem Header="_Copy URL"
Command="{Binding PlacementTarget.Tag.CopyEndpointUrlCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"/>
<Separator/>
<MenuItem Header="Toggle _enabled"
Command="{Binding DataContext.ToggleEnabledCommand, RelativeSource={RelativeSource AncestorType=Window}}"
CommandParameter="{Binding}"/>
Command="{Binding PlacementTarget.Tag.ToggleEnabledCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
CommandParameter="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource AncestorType=ContextMenu}}"/>
<Separator/>
<MenuItem Header="_Delete…" Command="{Binding DataContext.DeleteEndpointCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
<MenuItem Header="_Delete…"
Command="{Binding PlacementTarget.Tag.DeleteEndpointCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"/>
</ContextMenu>
</Setter.Value>
</Setter>
+36 -10
View File
@@ -1,3 +1,4 @@
using System.ComponentModel;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
@@ -11,26 +12,56 @@ public partial class MainWindow : Window
private readonly TrayIcon _tray;
private readonly MainViewModel _vm;
/// <summary>
/// Set to true when the user has explicitly asked to quit (File -> Exit or
/// Tray -> Exit). The OnClosing handler reads this to decide whether to
/// actually let the window close or hide it to the tray.
/// </summary>
public bool ExitForReal { get; set; }
public MainWindow()
{
InitializeComponent();
_vm = new MainViewModel(new AdminPipeClient());
DataContext = _vm;
_vm.RealExitRequested += OnRealExitRequested;
_tray = new TrayIcon(
resolveMainWindow: () => Application.Current.MainWindow,
restartServiceAsync: async () => await new AdminPipeClient().RestartListenerAsync());
restartServiceAsync: async () => await new AdminPipeClient().RestartListenerAsync(),
onExit: OnRealExitRequested);
Loaded += async (_, _) => await _vm.RefreshCommand.ExecuteAsync(null);
StateChanged += OnStateChanged;
Closed += (_, _) => _tray.Dispose();
Closing += OnClosing;
}
private void OnClosing(object? sender, CancelEventArgs e)
{
if (ExitForReal || !_vm.MinimizeToTrayEnabled)
{
_tray.Dispose();
return;
}
// Treat the X button / Alt+F4 like a minimize: hide to tray, keep the
// process alive so the tray icon persists.
e.Cancel = true;
Hide();
ShowInTaskbar = false;
}
private void OnRealExitRequested()
{
ExitForReal = true;
Application.Current.Shutdown();
}
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)
// Minimize-to-tray: hide the window when the user minimizes IF they've
// opted in via File -> Minimize to tray. Otherwise behave like a normal
// Windows minimize.
if (WindowState == WindowState.Minimized && _vm.MinimizeToTrayEnabled)
{
Hide();
ShowInTaskbar = false;
@@ -53,9 +84,4 @@ public partial class MainWindow : Window
vm.EditEndpointCommand.Execute(null);
}
private async void OnBackupsSubmenuOpened(object sender, RoutedEventArgs e)
{
if (DataContext is MainViewModel vm)
await vm.RefreshBackupsCommand.ExecuteAsync(null);
}
}
@@ -100,4 +100,7 @@ public sealed class AdminPipeClient
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,50 @@
using System.IO;
using System.Text.Json;
namespace WebhookServer.Gui.Services;
/// <summary>
/// Per-user GUI preferences that don't belong in the service-side ServerConfig.
/// Persisted to %APPDATA%\WebhookServer\gui.json. Best-effort: failures to read
/// or write fall back silently to defaults.
/// </summary>
public sealed class GuiSettings
{
/// <summary>
/// When true, the X / Alt+F4 / minimize buttons hide the window to the tray
/// and keep the GUI process alive. When false, X exits the app and minimize
/// behaves like a normal Windows minimize.
/// </summary>
public bool MinimizeToTrayEnabled { get; set; } = true;
private static string FilePath => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"WebhookServer",
"gui.json");
public static GuiSettings Load()
{
try
{
if (File.Exists(FilePath))
{
var json = File.ReadAllText(FilePath);
if (!string.IsNullOrWhiteSpace(json))
return JsonSerializer.Deserialize<GuiSettings>(json) ?? new GuiSettings();
}
}
catch { /* fall through to defaults */ }
return new GuiSettings();
}
public void Save()
{
try
{
var dir = Path.GetDirectoryName(FilePath);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
File.WriteAllText(FilePath, JsonSerializer.Serialize(this, new JsonSerializerOptions { WriteIndented = true }));
}
catch { /* best effort */ }
}
}
+4 -2
View File
@@ -16,11 +16,13 @@ public sealed class TrayIcon : IDisposable
private readonly NotifyIcon _icon;
private readonly Func<Window?> _resolveMainWindow;
private readonly Func<Task> _restartServiceAsync;
private readonly Action _onExit;
public TrayIcon(Func<Window?> resolveMainWindow, Func<Task> restartServiceAsync)
public TrayIcon(Func<Window?> resolveMainWindow, Func<Task> restartServiceAsync, Action onExit)
{
_resolveMainWindow = resolveMainWindow;
_restartServiceAsync = restartServiceAsync;
_onExit = onExit;
_icon = new NotifyIcon
{
@@ -39,7 +41,7 @@ public sealed class TrayIcon : IDisposable
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());
menu.Items.Add("E&xit", null, (_, _) => _onExit());
return menu;
}
@@ -0,0 +1,111 @@
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;
// Capture before the refresh; the ObservableCollection.Clear() in
// RefreshAsync nulls Selected (the original instance is gone from the
// collection so the SelectedItem binding clears).
var fileName = Selected.FileName;
var savedAt = Selected.SavedAt;
var ok = MessageBox.Show(
$"Roll the configuration back to the checkpoint from {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(fileName).ConfigureAwait(false);
await RefreshAsync().ConfigureAwait(false);
Application.Current.Dispatcher.Invoke(() =>
StatusMessage = $"Rolled back to {fileName}.");
}
catch (Exception ex)
{
Application.Current.Dispatcher.Invoke(() =>
MessageBox.Show(ex.Message, "Rollback failed", MessageBoxButton.OK, MessageBoxImage.Error));
}
}
}
@@ -29,17 +29,28 @@ public sealed partial class MainViewModel : ObservableObject
[ObservableProperty] private ServerConfig _serverConfig = new();
[ObservableProperty] private string _httpBaseUrl = "http://localhost:8080";
[ObservableProperty] private string? _httpsBaseUrl;
[ObservableProperty] private bool _minimizeToTrayEnabled;
private readonly DispatcherTimer _logTimer;
private readonly GuiSettings _settings;
public MainViewModel(AdminPipeClient client)
{
_client = client;
_settings = GuiSettings.Load();
_minimizeToTrayEnabled = _settings.MinimizeToTrayEnabled;
_logTimer = new DispatcherTimer(DispatcherPriority.Background) { Interval = TimeSpan.FromSeconds(3) };
_logTimer.Tick += async (_, _) => await RefreshLogTailAsync();
_logTimer.Start();
}
partial void OnMinimizeToTrayEnabledChanged(bool value)
{
_settings.MinimizeToTrayEnabled = value;
_settings.Save();
}
[RelayCommand]
private async Task RefreshAsync()
{
@@ -175,39 +186,18 @@ public sealed partial class MainViewModel : ObservableObject
}
}
[ObservableProperty] private System.Collections.ObjectModel.ObservableCollection<BackupEntry> _backups = new();
[RelayCommand]
private async Task RefreshBackupsAsync()
private void ShowConfigCheckpoints()
{
try
var dlg = new Views.ConfigCheckpointsDialog
{
var list = await _client.ListBackupsAsync().ConfigureAwait(false);
Application.Current.Dispatcher.Invoke(() =>
{
Backups.Clear();
foreach (var b in list) Backups.Add(b);
});
}
catch { /* ignore - checkpoint listing isn't critical */ }
}
[RelayCommand]
private async Task RestoreBackupAsync(BackupEntry? entry)
{
if (entry is null) return;
var ok = MessageBox.Show(
$"Restore the configuration from the checkpoint taken at {entry.SavedAt:yyyy-MM-dd HH:mm}?\n\nThe current configuration is automatically saved as a new checkpoint first, so you can roll forward again.",
"Restore checkpoint",
MessageBoxButton.OKCancel,
MessageBoxImage.Question);
if (ok != MessageBoxResult.OK) return;
try
{
await _client.RestoreBackupAsync(entry.FileName).ConfigureAwait(false);
await RefreshAsync().ConfigureAwait(false);
}
catch (Exception ex) { ShowError("Restore failed", ex); }
Owner = Application.Current.MainWindow,
DataContext = new ConfigCheckpointsViewModel(_client),
};
dlg.ShowDialog();
// After the dialog closes, the live config may have changed via rollback,
// so refresh the main grid.
_ = RefreshAsync();
}
[RelayCommand]
@@ -307,10 +297,14 @@ public sealed partial class MainViewModel : ObservableObject
}
}
/// <summary>Raised when the user picks File -> Exit. MainWindow flips its
/// ExitForReal flag and shuts down, bypassing the X-hides-to-tray logic.</summary>
public event Action? RealExitRequested;
[RelayCommand]
private void Exit()
{
Application.Current.Shutdown();
RealExitRequested?.Invoke();
}
[RelayCommand]
@@ -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();
}
}
@@ -225,16 +225,65 @@ internal sealed class AdminPipeServer : BackgroundService
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:
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
@@ -242,10 +291,23 @@ internal sealed class AdminPipeServer : BackgroundService
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.
@@ -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.AddHostedService<CallbackBackgroundService>();
builder.Services.AddHostedService<AdminPipeServer>();
builder.Services.AddHostedService<CheckpointScheduler>();
var app = builder.Build();