15 Commits

Author SHA1 Message Date
justin 8b41cc6b6c Bump version to 0.1.5 (#19)
Release (Gitea) / build-installer (push) Successful in 1m45s
2026-05-08 14:24:23 -04:00
justin 8512201ccc Auto-install .NET 8 runtimes if missing (#14) 2026-05-08 14:22:58 -04:00
justin c8aa711f8c Merge pull request 'Add ZVMA pre/post script recipe + env-dump examples' (#18) from claude/modest-fermat-db665b into main
Reviewed-on: #18
2026-05-08 14:20:02 -04:00
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
24 changed files with 1273 additions and 33 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] branches: [main]
pull_request: pull_request:
branches: [main] branches: [main]
workflow_dispatch:
jobs: jobs:
build: build:
+3
View File
@@ -12,6 +12,9 @@ on:
jobs: jobs:
build-installer: 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 runs-on: windows-latest
permissions: permissions:
contents: write # needed to create releases / upload assets contents: write # needed to create releases / upload assets
+4
View File
@@ -11,6 +11,10 @@ on:
jobs: jobs:
sync: 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 runs-on: windows-latest
permissions: permissions:
contents: write contents: write
+1 -1
View File
@@ -1,7 +1,7 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<Version>0.1.2</Version> <Version>0.1.5</Version>
<Authors>Justin Paul</Authors> <Authors>Justin Paul</Authors>
<Company>Justin Paul</Company> <Company>Justin Paul</Company>
<Product>Webhook Server</Product> <Product>Webhook Server</Product>
+4 -3
View File
@@ -7,7 +7,7 @@ Designed for sysadmins who want to wire up tools like **Zerto pre/post scripts**
## Quickstart ## Quickstart
1. **Download** the latest installer: <https://github.com/recklessop/webhook-server/releases/latest> 1. **Download** the latest installer: <https://github.com/recklessop/webhook-server/releases/latest>
2. **Run it.** UAC accept → next, next, finish. Adds a Start Menu entry, registers and starts the Windows Service. 2. **Run it.** UAC accept → next, next, finish. Adds a Start Menu entry, registers and starts the Windows Service. The installer also downloads + installs the **.NET 8 runtimes** (ASP.NET Core + Desktop) if they're missing — fresh Windows Server installs need this.
3. **Open Webhook Server** from the Start Menu (auto-elevates). 3. **Open Webhook Server** from the Start Menu (auto-elevates).
4. **File → New endpoint**, configure a slug + script, save, hit the URL. 4. **File → New endpoint**, configure a slug + script, save, hit the URL.
@@ -61,11 +61,12 @@ Everything you need to operate the server:
Recipes: Recipes:
- [Zerto failover post-script → DNS + service checks](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) - [GitHub-style HMAC-signed webhook](docs/recipes/github-style-hmac.md)
- [Pop UI on the user's desktop](docs/recipes/ui-on-desktop.md) - [Pop UI on the user's desktop](docs/recipes/ui-on-desktop.md)
A ready-to-drop-in Zerto-side script is included at [`scripts/examples/zerto-post-failover.ps1`](scripts/examples/zerto-post-failover.ps1). 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 ## Requirements
+3 -2
View File
@@ -19,11 +19,12 @@ Webhook Server is a Windows service that runs a script (PowerShell, cmd, or any
## Recipes (cookbook style) ## Recipes (cookbook style)
- [Zerto failover post-script → DNS + service checks](recipes/zerto-pre-post-scripts.md) ← canonical use case - [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) - [GitHub-style HMAC-signed webhook](recipes/github-style-hmac.md)
- [Pop UI on the user's desktop](recipes/ui-on-desktop.md) - [Pop UI on the user's desktop](recipes/ui-on-desktop.md)
The flagship Zerto recipe also ships with a **ready-to-use Zerto-side post-script** at [`scripts/examples/zerto-post-failover.ps1`](../scripts/examples/zerto-post-failover.ps1). 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 ## Reference
+24
View File
@@ -6,10 +6,34 @@ This page covers a fresh install. If you already have Webhook Server installed,
- Windows 10, Windows 11, or Windows Server 2019 / 2022 / 2025 - Windows 10, Windows 11, or Windows Server 2019 / 2022 / 2025
- Administrator rights to install the service and to run the GUI - Administrator rights to install the service and to run the GUI
- **.NET 8 runtimes** (the installer downloads + installs them automatically if missing — see below)
- (Optional, only if you publish from source) .NET 8 SDK - (Optional, only if you publish from source) .NET 8 SDK
The installer is **x64 only**. There is no x86 build. The installer is **x64 only**. There is no x86 build.
### .NET 8 runtimes
Webhook Server is published as framework-dependent (so the installer stays small) and needs two .NET 8 runtimes on the target machine:
| Runtime | Used by | Auto-installed by setup |
|---|---|---|
| ASP.NET Core 8 Runtime (`Microsoft.AspNetCore.App` 8.x) | the Service / Kestrel | Yes |
| .NET Desktop Runtime 8 (`Microsoft.WindowsDesktop.App` 8.x) | the WPF GUI | Yes |
A clean Windows Server install has neither. The installer detects what's missing and downloads + installs each one silently before copying our files. If the machine has no internet access, install them manually first:
- ASP.NET Core 8 Runtime — <https://aka.ms/dotnet/8.0/aspnetcore-runtime-win-x64.exe>
- .NET Desktop Runtime 8 — <https://aka.ms/dotnet/8.0/windowsdesktop-runtime-win-x64.exe>
Run each with `/install /quiet /norestart` for unattended installs, or just double-click. A reboot is rarely required.
To check what's already installed:
```powershell
dotnet --list-runtimes
# expect to see Microsoft.AspNetCore.App 8.x.y and Microsoft.WindowsDesktop.App 8.x.y
```
## 1. Download ## 1. Download
Grab the latest installer from the GitHub Releases page: Grab the latest installer from the GitHub Releases page:
+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.
+22
View File
@@ -38,6 +38,28 @@ You launched the GUI without elevation. The admin pipe ACL is `SYSTEM` + `Admini
**Fix in v0.1.0**: right-click the Start Menu shortcut → **Run as administrator**, or upgrade. **Fix in v0.1.0**: right-click the Start Menu shortcut → **Run as administrator**, or upgrade.
### Service won't start after install / GUI says "Disconnected" with no obvious error
If `Get-Service WebhookServer` shows it stopped and `Start-Service WebhookServer` fails, or the GUI itself won't even launch, you're probably missing a .NET 8 runtime. The v0.1.4+ installer auto-fetches them, but a clean Windows Server box might still hit this if the install was offline or used an older installer.
Check what's installed:
```powershell
dotnet --list-runtimes
```
You need both:
- `Microsoft.AspNetCore.App 8.x.y` — for the Service
- `Microsoft.WindowsDesktop.App 8.x.y` — for the GUI
If either is missing, install from:
- ASP.NET Core 8 Runtime — <https://aka.ms/dotnet/8.0/aspnetcore-runtime-win-x64.exe>
- .NET Desktop Runtime 8 — <https://aka.ms/dotnet/8.0/windowsdesktop-runtime-win-x64.exe>
Re-run with `/install /quiet /norestart` for unattended installs. Then `Start-Service WebhookServer`.
### "Connection refused" hitting the hook URL ### "Connection refused" hitting the hook URL
Three possibilities, in order of probability: Three possibilities, in order of probability:
+124
View File
@@ -86,6 +86,17 @@ Filename: "powershell.exe"; \
RunOnceId: "RemoveWebhookService" RunOnceId: "RemoveWebhookService"
[Code] [Code]
const
// aka.ms redirects to the latest 8.0.x patch. Inno Setup's downloader
// follows redirects via the Windows HTTP stack.
AspNetCore8Url = 'https://aka.ms/dotnet/8.0/aspnetcore-runtime-win-x64.exe';
WinDesktop8Url = 'https://aka.ms/dotnet/8.0/windowsdesktop-runtime-win-x64.exe';
AspNetCore8File = 'aspnetcore-runtime-8.0-win-x64.exe';
WinDesktop8File = 'windowsdesktop-runtime-8.0-win-x64.exe';
var
DownloadPage: TDownloadWizardPage;
function ServiceExists(): Boolean; function ServiceExists(): Boolean;
var var
ResultCode: Integer; ResultCode: Integer;
@@ -96,6 +107,119 @@ begin
Result := (ResultCode = 0); Result := (ResultCode = 0);
end; end;
// True if a Microsoft.* shared-framework directory under
// %ProgramFiles%\dotnet\shared contains at least one 8.x.y subfolder.
function HasDotNet8(const RuntimeName: String): Boolean;
var
rec: TFindRec;
base: String;
begin
Result := False;
base := ExpandConstant('{commonpf}\dotnet\shared\') + RuntimeName;
if not DirExists(base) then Exit;
if FindFirst(base + '\8.*', rec) then
try
repeat
if (rec.Name <> '.') and (rec.Name <> '..') and
DirExists(base + '\' + rec.Name) then begin
Result := True;
Exit;
end;
until not FindNext(rec);
finally
FindClose(rec);
end;
end;
function NeedsAspNet8(): Boolean;
begin
Result := not HasDotNet8('Microsoft.AspNetCore.App');
end;
function NeedsWinDesktop8(): Boolean;
begin
Result := not HasDotNet8('Microsoft.WindowsDesktop.App');
end;
procedure InitializeWizard;
begin
DownloadPage := CreateDownloadPage(
'Downloading prerequisites',
'Webhook Server needs the .NET 8 runtimes. Setup is fetching them now.',
nil);
end;
// Runs a downloaded runtime installer silently. Treats Microsoft's
// "success but reboot pending" / "newer already installed" exit codes
// as successes so we don't fail the whole install over a benign result.
function RunRuntimeInstaller(const FileName, DisplayName: String): String;
var
resultCode: Integer;
fullPath: String;
begin
Result := '';
fullPath := ExpandConstant('{tmp}\') + FileName;
if not Exec(fullPath, '/install /quiet /norestart', '', SW_HIDE,
ewWaitUntilTerminated, resultCode) then begin
Result := 'Could not launch the ' + DisplayName + ' installer.';
Exit;
end;
case resultCode of
0, 1638, 3010, 1641: ;
else
Result := DisplayName + ' installer failed (exit code ' +
IntToStr(resultCode) + ').';
end;
end;
function NextButtonClick(CurPageID: Integer): Boolean;
var
errMsg: String;
begin
Result := True;
if CurPageID <> wpReady then Exit;
if not (NeedsAspNet8 or NeedsWinDesktop8) then Exit;
DownloadPage.Clear;
if NeedsAspNet8 then
DownloadPage.Add(AspNetCore8Url, AspNetCore8File, '');
if NeedsWinDesktop8 then
DownloadPage.Add(WinDesktop8Url, WinDesktop8File, '');
DownloadPage.Show;
try
try
DownloadPage.Download;
except
if MsgBox('Failed to download the .NET 8 runtimes:' + #13#10#13#10 +
GetExceptionMessage + #13#10#13#10 +
'Continue installing anyway? Webhook Server will not start ' +
'until the runtimes are installed manually.',
mbError, MB_YESNO) = IDNO then
Result := False;
Exit;
end;
finally
DownloadPage.Hide;
end;
if NeedsAspNet8 then begin
errMsg := RunRuntimeInstaller(AspNetCore8File, 'ASP.NET Core 8 Runtime');
if errMsg <> '' then begin
MsgBox(errMsg, mbError, MB_OK);
Result := False;
Exit;
end;
end;
if NeedsWinDesktop8 then begin
errMsg := RunRuntimeInstaller(WinDesktop8File, '.NET Desktop Runtime 8');
if errMsg <> '' then begin
MsgBox(errMsg, mbError, MB_OK);
Result := False;
Exit;
end;
end;
end;
function PrepareToInstall(var NeedsRestart: Boolean): String; function PrepareToInstall(var NeedsRestart: Boolean): String;
var var
ResultCode: Integer; ResultCode: Integer;
+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 -c $Configuration -r win-x64 --self-contained false -o $publishGui | Out-Host
if ($LASTEXITCODE -ne 0) { throw 'GUI publish failed' } 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 $iscc = Find-InnoCompiler
$iss = Join-Path $repoRoot 'installer\webhook-server.iss' $iss = Join-Path $repoRoot 'installer\webhook-server.iss'
$dist = Join-Path $repoRoot 'dist' $dist = Join-Path $repoRoot 'dist'
New-Item -ItemType Directory -Path $dist -Force | Out-Null New-Item -ItemType Directory -Path $dist -Force | Out-Null
Write-Host "Compiling installer with $iscc" Write-Host "Compiling installer with $iscc"
& $iscc "/DAppVersion=$version" $iss # Run ISCC from the .iss directory with just the bare filename. When invoked
if ($LASTEXITCODE -ne 0) { throw 'Inno Setup compile failed' } # 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") $out = Get-Item (Join-Path $dist "WebhookServer-Setup-$version.exe")
Write-Host "" 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
@@ -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)"
}
+20 -8
View File
@@ -44,7 +44,12 @@ param(
[string]$AuthorEmail = 'noreply@jpaul.me' [string]$AuthorEmail = 'noreply@jpaul.me'
) )
$ErrorActionPreference = 'Stop' # 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 $repoRoot = Split-Path -Parent $PSScriptRoot
$docsDir = Join-Path $repoRoot 'docs' $docsDir = Join-Path $repoRoot 'docs'
$workDir = Join-Path $env:TEMP ("webhook-wiki-{0}" -f ([guid]::NewGuid().ToString('N').Substring(0, 8))) $workDir = Join-Path $env:TEMP ("webhook-wiki-{0}" -f ([guid]::NewGuid().ToString('N').Substring(0, 8)))
@@ -98,10 +103,14 @@ function New-Sidebar() {
# 1. Clone the wiki. # 1. Clone the wiki.
Write-Host "Cloning wiki to $workDir..." Write-Host "Cloning wiki to $workDir..."
git clone --quiet $WikiUrl $workDir & git clone --quiet $WikiUrl $workDir 2>&1 | Out-Null
if ($LASTEXITCODE -ne 0) { 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." 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 { try {
Push-Location $workDir Push-Location $workDir
@@ -128,16 +137,19 @@ try {
# 4. Sidebar # 4. Sidebar
Set-Content -LiteralPath (Join-Path $workDir '_Sidebar.md') -Value (New-Sidebar) -Encoding utf8 -NoNewline Set-Content -LiteralPath (Join-Path $workDir '_Sidebar.md') -Value (New-Sidebar) -Encoding utf8 -NoNewline
# 5. Commit + push if anything actually changed. # 5. Commit + push if anything actually changed. Drain stderr from each
git add -A # git invocation so PowerShell doesn't treat warnings as errors.
$changes = git status --porcelain & git add -A 2>&1 | Out-Null
$changes = & git status --porcelain 2>&1
if (-not $changes) { if (-not $changes) {
Write-Host "Wiki already up to date." Write-Host "Wiki already up to date."
return return
} }
$sha = git -C $repoRoot rev-parse --short HEAD $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" & git -c "user.name=$AuthorName" -c "user.email=$AuthorEmail" commit -q -m "Sync from docs/ at $sha" 2>&1 | Out-Null
git push --quiet 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." Write-Host "Pushed updated wiki."
} }
finally { Pop-Location } finally { Pop-Location }
+19 -5
View File
@@ -31,6 +31,11 @@
<MenuItem Header="_Export config…" Command="{Binding ExportConfigCommand}"/> <MenuItem Header="_Export config…" Command="{Binding ExportConfigCommand}"/>
<MenuItem Header="Config _Checkpoints…" Command="{Binding ShowConfigCheckpointsCommand}"/> <MenuItem Header="Config _Checkpoints…" Command="{Binding ShowConfigCheckpointsCommand}"/>
<Separator/> <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 Header="E_xit" Command="{Binding ExitCommand}"/>
</MenuItem> </MenuItem>
<MenuItem Header="_Server"> <MenuItem Header="_Server">
@@ -63,17 +68,26 @@
<DataGrid.RowStyle> <DataGrid.RowStyle>
<Style TargetType="DataGridRow"> <Style TargetType="DataGridRow">
<EventSetter Event="MouseDoubleClick" Handler="OnRowDoubleClick"/> <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 Property="ContextMenu">
<Setter.Value> <Setter.Value>
<ContextMenu> <ContextMenu>
<MenuItem Header="_Edit…" Command="{Binding DataContext.EditEndpointCommand, RelativeSource={RelativeSource AncestorType=Window}}"/> <MenuItem Header="_Edit…"
<MenuItem Header="_Copy URL" Command="{Binding DataContext.CopyEndpointUrlCommand, RelativeSource={RelativeSource AncestorType=Window}}"/> Command="{Binding PlacementTarget.Tag.EditEndpointCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"/>
<MenuItem Header="_Copy URL"
Command="{Binding PlacementTarget.Tag.CopyEndpointUrlCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"/>
<Separator/> <Separator/>
<MenuItem Header="Toggle _enabled" <MenuItem Header="Toggle _enabled"
Command="{Binding DataContext.ToggleEnabledCommand, RelativeSource={RelativeSource AncestorType=Window}}" Command="{Binding PlacementTarget.Tag.ToggleEnabledCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
CommandParameter="{Binding}"/> CommandParameter="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource AncestorType=ContextMenu}}"/>
<Separator/> <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> </ContextMenu>
</Setter.Value> </Setter.Value>
</Setter> </Setter>
+36 -5
View File
@@ -1,3 +1,4 @@
using System.ComponentModel;
using System.Windows; using System.Windows;
using System.Windows.Controls; using System.Windows.Controls;
using System.Windows.Input; using System.Windows.Input;
@@ -11,26 +12,56 @@ public partial class MainWindow : Window
private readonly TrayIcon _tray; private readonly TrayIcon _tray;
private readonly MainViewModel _vm; 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() public MainWindow()
{ {
InitializeComponent(); InitializeComponent();
_vm = new MainViewModel(new AdminPipeClient()); _vm = new MainViewModel(new AdminPipeClient());
DataContext = _vm; DataContext = _vm;
_vm.RealExitRequested += OnRealExitRequested;
_tray = new TrayIcon( _tray = new TrayIcon(
resolveMainWindow: () => Application.Current.MainWindow, 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); Loaded += async (_, _) => await _vm.RefreshCommand.ExecuteAsync(null);
StateChanged += OnStateChanged; 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) private void OnStateChanged(object? sender, EventArgs e)
{ {
// Minimize-to-tray: hide the window when the user minimizes; restoring is // Minimize-to-tray: hide the window when the user minimizes IF they've
// via the tray icon's double-click or context menu. // opted in via File -> Minimize to tray. Otherwise behave like a normal
if (WindowState == WindowState.Minimized) // Windows minimize.
if (WindowState == WindowState.Minimized && _vm.MinimizeToTrayEnabled)
{ {
Hide(); Hide();
ShowInTaskbar = false; ShowInTaskbar = false;
@@ -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 NotifyIcon _icon;
private readonly Func<Window?> _resolveMainWindow; private readonly Func<Window?> _resolveMainWindow;
private readonly Func<Task> _restartServiceAsync; 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; _resolveMainWindow = resolveMainWindow;
_restartServiceAsync = restartServiceAsync; _restartServiceAsync = restartServiceAsync;
_onExit = onExit;
_icon = new NotifyIcon _icon = new NotifyIcon
{ {
@@ -39,7 +41,7 @@ public sealed class TrayIcon : IDisposable
menu.Items.Add(new ToolStripSeparator()); menu.Items.Add(new ToolStripSeparator());
menu.Items.Add("&Restart service", null, async (_, _) => await _restartServiceAsync().ConfigureAwait(false)); menu.Items.Add("&Restart service", null, async (_, _) => await _restartServiceAsync().ConfigureAwait(false));
menu.Items.Add(new ToolStripSeparator()); menu.Items.Add(new ToolStripSeparator());
menu.Items.Add("E&xit", null, (_, _) => Application.Current.Shutdown()); menu.Items.Add("E&xit", null, (_, _) => _onExit());
return menu; return menu;
} }
@@ -82,8 +82,14 @@ public sealed partial class ConfigCheckpointsViewModel : ObservableObject
{ {
if (Selected is null) return; 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( var ok = MessageBox.Show(
$"Roll the configuration back to the checkpoint from {Selected.SavedAt.ToLocalTime():yyyy-MM-dd HH:mm:ss}?\n\nThe current configuration is automatically saved as a new checkpoint first, so you can roll forward again.", $"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", "Confirm rollback",
MessageBoxButton.OKCancel, MessageBoxButton.OKCancel,
MessageBoxImage.Warning); MessageBoxImage.Warning);
@@ -91,10 +97,10 @@ public sealed partial class ConfigCheckpointsViewModel : ObservableObject
try try
{ {
await _client.RestoreBackupAsync(Selected.FileName).ConfigureAwait(false); await _client.RestoreBackupAsync(fileName).ConfigureAwait(false);
await RefreshAsync().ConfigureAwait(false); await RefreshAsync().ConfigureAwait(false);
Application.Current.Dispatcher.Invoke(() => Application.Current.Dispatcher.Invoke(() =>
StatusMessage = $"Rolled back to {Selected!.FileName}."); StatusMessage = $"Rolled back to {fileName}.");
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -29,17 +29,28 @@ public sealed partial class MainViewModel : ObservableObject
[ObservableProperty] private ServerConfig _serverConfig = new(); [ObservableProperty] private ServerConfig _serverConfig = new();
[ObservableProperty] private string _httpBaseUrl = "http://localhost:8080"; [ObservableProperty] private string _httpBaseUrl = "http://localhost:8080";
[ObservableProperty] private string? _httpsBaseUrl; [ObservableProperty] private string? _httpsBaseUrl;
[ObservableProperty] private bool _minimizeToTrayEnabled;
private readonly DispatcherTimer _logTimer; private readonly DispatcherTimer _logTimer;
private readonly GuiSettings _settings;
public MainViewModel(AdminPipeClient client) public MainViewModel(AdminPipeClient client)
{ {
_client = client; _client = client;
_settings = GuiSettings.Load();
_minimizeToTrayEnabled = _settings.MinimizeToTrayEnabled;
_logTimer = new DispatcherTimer(DispatcherPriority.Background) { Interval = TimeSpan.FromSeconds(3) }; _logTimer = new DispatcherTimer(DispatcherPriority.Background) { Interval = TimeSpan.FromSeconds(3) };
_logTimer.Tick += async (_, _) => await RefreshLogTailAsync(); _logTimer.Tick += async (_, _) => await RefreshLogTailAsync();
_logTimer.Start(); _logTimer.Start();
} }
partial void OnMinimizeToTrayEnabledChanged(bool value)
{
_settings.MinimizeToTrayEnabled = value;
_settings.Save();
}
[RelayCommand] [RelayCommand]
private async Task RefreshAsync() private async Task RefreshAsync()
{ {
@@ -286,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] [RelayCommand]
private void Exit() private void Exit()
{ {
Application.Current.Shutdown(); RealExitRequested?.Invoke();
} }
[RelayCommand] [RelayCommand]