From 80de553832409cd682b0c817e7ea0d0ec07f1d1f Mon Sep 17 00:00:00 2001 From: Justin Paul Date: Fri, 8 May 2026 14:41:57 -0400 Subject: [PATCH] Sync from Gitea: v0.1.5 release Brings GitHub main up to match git.jpaul.io/justin/webhook-server main: - Auto-install .NET 8 runtimes if missing (Inno Setup [Code] + TDownloadWizardPage). Detects via %ProgramFiles%\dotnet\shared\ Microsoft.{AspNetCore,WindowsDesktop}.App\8.* and installs from aka.ms/dotnet/8.0/* with /install /quiet /norestart. - ZVMA pre/post script recipe + env-dump examples (docs/recipes/zerto-zvma-pre-post.md, scripts/examples/*). - Drop GHES-incompatible upload-artifact@v4 from the Gitea workflow. - build-installer.ps1 picks up cumulative diagnostics + ISCC cwd fix (these were needed to debug the gitea-runner SYSTEM context issue - keep for now, can be trimmed in a follow-up). - Bump version to 0.1.5. Both Gitea and GitHub releases for v0.1.5 are already published; this just brings the source histories back in sync. --- .gitea/workflows/release.yml | 8 +- Directory.Build.props | 2 +- README.md | 7 +- docs/README.md | 5 +- docs/installation.md | 24 ++ docs/recipes/zerto-zvma-pre-post.md | 277 ++++++++++++++++++ docs/troubleshooting.md | 22 ++ installer/webhook-server.iss | 124 ++++++++ scripts/build-installer.ps1 | 96 +++++- scripts/examples/save-env-vars.ps1 | 46 +++ scripts/examples/send-env-vars.ps1 | 68 +++++ scripts/examples/zerto-receiver-notify.ps1 | 90 ++++++ .../zerto-receiver-vm-healthcheck.ps1 | 140 +++++++++ scripts/examples/zerto-zvma-send.ps1 | 74 +++++ 14 files changed, 970 insertions(+), 13 deletions(-) create mode 100644 docs/recipes/zerto-zvma-pre-post.md create mode 100644 scripts/examples/save-env-vars.ps1 create mode 100644 scripts/examples/send-env-vars.ps1 create mode 100644 scripts/examples/zerto-receiver-notify.ps1 create mode 100644 scripts/examples/zerto-receiver-vm-healthcheck.ps1 create mode 100644 scripts/examples/zerto-zvma-send.ps1 diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 0abb5ee..1777aa3 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -62,11 +62,9 @@ jobs: shell: pwsh run: ./scripts/build-installer.ps1 -VersionOverride ${{ steps.ver.outputs.version }} - - name: Upload installer artifact - uses: actions/upload-artifact@v4 - with: - name: WebhookServer-Setup-${{ steps.ver.outputs.version }} - path: dist/WebhookServer-Setup-*.exe + # 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') diff --git a/Directory.Build.props b/Directory.Build.props index 78ecee5..65391ee 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,7 +1,7 @@ - 0.1.4 + 0.1.5 Justin Paul Justin Paul Webhook Server diff --git a/README.md b/README.md index 0d450df..3548879 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Designed for sysadmins who want to wire up tools like **Zerto pre/post scripts** ## Quickstart 1. **Download** the latest installer: -2. **Run it.** UAC accept → next, next, finish. Adds a Start Menu entry, registers and starts the Windows Service. +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). 4. **File → New endpoint**, configure a slug + script, save, hit the URL. @@ -61,11 +61,12 @@ Everything you need to operate the server: 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) - [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 diff --git a/docs/README.md b/docs/README.md index c4e5630..b61338f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -19,11 +19,12 @@ Webhook Server is a Windows service that runs a script (PowerShell, cmd, or any ## 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) - [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 diff --git a/docs/installation.md b/docs/installation.md index 3226555..89ae2b4 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -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 - 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 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 — +- .NET Desktop Runtime 8 — + +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 Grab the latest installer from the GitHub Releases page: diff --git a/docs/recipes/zerto-zvma-pre-post.md b/docs/recipes/zerto-zvma-pre-post.md new file mode 100644 index 0000000..7b8b459 --- /dev/null +++ b/docs/recipes/zerto-zvma-pre-post.md @@ -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\--.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 ` +> +> VPG settings → Post-Recovery Script → Parameters: +> `-Phase post -Bearer ` + +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 ~30s–several 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 `-Test-.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. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 5fecbd1..58978da 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -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. +### 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 — +- .NET Desktop Runtime 8 — + +Re-run with `/install /quiet /norestart` for unattended installs. Then `Start-Service WebhookServer`. + ### "Connection refused" hitting the hook URL Three possibilities, in order of probability: diff --git a/installer/webhook-server.iss b/installer/webhook-server.iss index 562546b..90ed61f 100644 --- a/installer/webhook-server.iss +++ b/installer/webhook-server.iss @@ -86,6 +86,17 @@ Filename: "powershell.exe"; \ RunOnceId: "RemoveWebhookService" [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; var ResultCode: Integer; @@ -96,6 +107,119 @@ begin Result := (ResultCode = 0); 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; var ResultCode: Integer; diff --git a/scripts/build-installer.ps1 b/scripts/build-installer.ps1 index 3ff9c2b..ade5a3e 100644 --- a/scripts/build-installer.ps1 +++ b/scripts/build-installer.ps1 @@ -96,12 +96,104 @@ Write-Host "Compiling installer with $iscc" # 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 " cwd=$issDir" - & $iscc "/DAppVersion=$version" $issName + 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 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)" } diff --git a/scripts/examples/save-env-vars.ps1 b/scripts/examples/save-env-vars.ps1 new file mode 100644 index 0000000..91d2d38 --- /dev/null +++ b/scripts/examples/save-env-vars.ps1 @@ -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\-.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" diff --git a/scripts/examples/send-env-vars.ps1 b/scripts/examples/send-env-vars.ps1 new file mode 100644 index 0000000..ffc6527 --- /dev/null +++ b/scripts/examples/send-env-vars.ps1 @@ -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": "", + "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 diff --git a/scripts/examples/zerto-receiver-notify.ps1 b/scripts/examples/zerto-receiver-notify.ps1 new file mode 100644 index 0000000..b99b13b --- /dev/null +++ b/scripts/examples/zerto-receiver-notify.ps1 @@ -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 +} diff --git a/scripts/examples/zerto-receiver-vm-healthcheck.ps1 b/scripts/examples/zerto-receiver-vm-healthcheck.ps1 new file mode 100644 index 0000000..5942546 --- /dev/null +++ b/scripts/examples/zerto-receiver-vm-healthcheck.ps1 @@ -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 } diff --git a/scripts/examples/zerto-zvma-send.ps1 b/scripts/examples/zerto-zvma-send.ps1 new file mode 100644 index 0000000..cc93e0f --- /dev/null +++ b/scripts/examples/zerto-zvma-send.ps1 @@ -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)" +}