4 Commits

Author SHA1 Message Date
justin c2ecb6b564 Documentation: install/upgrade/uninstall guides + recipes incl. Zerto
Adds a docs/ folder under the repo root with full operator documentation
aimed at sysadmins (not webhook developers). The Zerto pre/post script
recipe is the canonical "why does this exist" walkthrough; the GitHub
HMAC, AD password reset, and UI-on-desktop recipes round out common
patterns.

Pages:
- README.md (index)
- concepts.md (5-minute "what is a webhook" explainer)
- installation.md (interactive + silent install)
- upgrading.md (single-click upgrade flow + edge cases)
- uninstalling.md (clean removal + wiping ProgramData)
- runas-modes.md (Service / InteractiveUser / SpecificUser decision flow)
- service-account-and-ad.md (gMSA setup, delegated rights)
- network-and-security.md (bind addresses, allowlists, HTTPS, secret storage)
- troubleshooting.md (symptom -> first check, common errors)
- recipes/zerto-pre-post-scripts.md (canonical use case)
- recipes/github-style-hmac.md (GitHub / Stripe-shaped webhooks)
- recipes/ad-password-reset.md (gMSA-backed self-service reset)
- recipes/ui-on-desktop.md (InteractiveUser pattern)

Top-level README.md restructured to point at docs/ as the source of
truth, dropping the duplicated installation snippets.

Installer ships docs/ alongside the binaries so they're available
offline at C:\Program Files\WebhookServer\docs\. GUI Help menu gains
a "Documentation" item that opens the docs site in a browser.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 10:34:47 -04:00
justin 0217d9f307 Installer: synchronous service stop + kill stray GUI/Service processes
The previous sc.exe stop is fire-and-forget; on slower machines the
file-copy step started before the service had actually released its
binaries, leaving the upgrade in a broken state. Switch to net.exe
stop which blocks until the service reports STOPPED.

Also taskkill any running WebhookServer.Gui.exe (the user might have
left the tray running) and any orphan WebhookServer.Service.exe (from
deploy.ps1 dev runs) so all copies of the binaries are unlocked
before [Files] runs.

Pre-flight ServiceExists() check via sc query so the installer only
calls "net stop" when there is actually a service to stop, rather
than relying on net's error code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 10:22:05 -04:00
justin baecc99b23 Rename "Backups" menu item to "Config Checkpoints"
User-facing copy only; internal API names (Backups collection,
BackupEntry, list-backups op, etc.) stay the same to avoid churn
through the wire protocol and existing on-disk files. The new
phrasing makes the auto-snapshot-before-save model more discoverable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 10:22:05 -04:00
justin c9c5bd37d2 v0.1.1: GUI auto-elevates, installer stops service before file copy
Two fixes for the v0.1.0 install experience:

1. Embed app.manifest with requestedExecutionLevel=requireAdministrator
   so the GUI always elevates. The named pipe is ACL'd to SYSTEM and
   the Administrators group, but UAC token splitting puts Admins in
   deny-only on the standard token, so launching the GUI from the
   Start Menu fails to connect with "Access is denied". The manifest
   forces UAC to elevate, surfaces the shield icon on the shortcut,
   and matches the reality that the GUI cannot function without
   admin rights.

2. Add a [Code] PrepareToInstall hook to webhook-server.iss that runs
   `sc stop WebhookServer` before file copy. Upgrade installs were
   failing on locked binaries because the running service held the
   exes open. sc returns non-zero on fresh installs (no service yet)
   which we ignore.

Bumps Version to 0.1.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 10:22:05 -04:00
29 changed files with 319 additions and 1111 deletions
-102
View File
@@ -1,102 +0,0 @@
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 }}
- name: Upload installer artifact
uses: actions/upload-artifact@v4
with:
name: WebhookServer-Setup-${{ steps.ver.outputs.version }}
path: dist/WebhookServer-Setup-*.exe
- 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,7 +5,6 @@ on:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
jobs:
build:
-3
View File
@@ -12,9 +12,6 @@ on:
jobs:
build-installer:
# Gitea reads .github/workflows/ for compatibility, but the create-release
# step uses a GitHub-only action. Skip the whole job on non-GitHub runners.
if: github.server_url == 'https://github.com'
runs-on: windows-latest
permissions:
contents: write # needed to create releases / upload assets
-31
View File
@@ -1,31 +0,0 @@
name: Sync Wiki
on:
push:
branches: [main]
paths:
- 'docs/**'
- 'scripts/sync-wiki.ps1'
- '.github/workflows/wiki-sync.yml'
workflow_dispatch:
jobs:
sync:
# Gitea reads .github/workflows/ for compatibility, but this workflow
# pushes to a GitHub-hosted wiki. Skip on non-GitHub runners; the Gitea
# wiki is synced separately via scripts/sync-wiki.ps1.
if: github.server_url == 'https://github.com'
runs-on: windows-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- name: Sync docs/ to GitHub wiki
shell: pwsh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
$repo = '${{ github.repository }}'
$wikiUrl = "https://x-access-token:$env:GH_TOKEN@github.com/$repo.wiki.git"
./scripts/sync-wiki.ps1 -WikiUrl $wikiUrl
+1 -1
View File
@@ -1,7 +1,7 @@
<Project>
<PropertyGroup>
<Version>0.1.4</Version>
<Version>0.1.1</Version>
<Authors>Justin Paul</Authors>
<Company>Justin Paul</Company>
<Product>Webhook Server</Product>
+2 -3
View File
@@ -61,12 +61,11 @@ 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 pre/post scriptsAD / DNS update](docs/recipes/zerto-pre-post-scripts.md) ← **canonical use case**
- [GitHub-style HMAC-signed webhook](docs/recipes/github-style-hmac.md)
- [AD password reset endpoint](docs/recipes/ad-password-reset.md)
- [Pop UI on the user's desktop](docs/recipes/ui-on-desktop.md)
A ready-to-drop-in Zerto-side script is included at [`scripts/examples/zerto-post-failover.ps1`](scripts/examples/zerto-post-failover.ps1).
## Requirements
- Windows 10 / 11 / Server 2019+
+3 -4
View File
@@ -6,7 +6,7 @@ Webhook Server is a Windows service that runs a script (PowerShell, cmd, or any
1. [Concepts](concepts.md) — five-minute read on what a webhook is and how this server uses one
2. [Installation](installation.md) — download, install, first endpoint
3. [Recipe: Zerto failover post-script → DNS + service checks](recipes/zerto-pre-post-scripts.md) — the canonical reason this exists
3. [Recipe: Zerto pre/post scriptsAD / DNS update](recipes/zerto-pre-post-scripts.md) — the canonical reason this exists
## Topical
@@ -19,12 +19,11 @@ 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 pre/post scriptsAD / DNS update](recipes/zerto-pre-post-scripts.md)
- [GitHub-style HMAC-signed webhook](recipes/github-style-hmac.md)
- [AD password reset endpoint](recipes/ad-password-reset.md)
- [Pop UI on the user's desktop](recipes/ui-on-desktop.md)
The flagship Zerto recipe also ships with a **ready-to-use Zerto-side post-script** at [`scripts/examples/zerto-post-failover.ps1`](../scripts/examples/zerto-post-failover.ps1).
## Reference
- [GitHub repo](https://github.com/recklessop/webhook-server)
+105
View File
@@ -0,0 +1,105 @@
# Recipe: AD password reset endpoint
A self-service password reset URL your help-desk tool can hit. Single endpoint, gMSA-backed, audited.
## Architecture
- The webhook host is domain-joined
- The service runs as a gMSA with **Reset Password** + **Write pwdLastSet** delegated on the OUs containing target users
- The endpoint is HMAC-signed, IP-allowlisted to the help-desk app's server
- Every reset is logged in the daily log file with caller IP, target user, runId, and result
## Prerequisites
- gMSA created and installed on the host. See [Service account & Active Directory](../service-account-and-ad.md).
- Service installed with `-ServiceAccount 'CONTOSO\svc-webhookserver$'`
- Delegate the right permissions on the OU(s):
```powershell
$ou = "OU=Standard Users,DC=contoso,DC=local"
dsacls $ou /I:S /G "CONTOSO\svc-webhookserver$:CA;Reset Password;user"
dsacls $ou /I:S /G "CONTOSO\svc-webhookserver$:WP;pwdLastSet;user"
```
## The script
`C:\Scripts\ad-password-reset.ps1`:
```powershell
[CmdletBinding()]
param()
$ErrorActionPreference = 'Stop'
Import-Module ActiveDirectory
$body = $input | ConvertFrom-Json
if (-not $body.samAccountName) { throw 'samAccountName is required' }
if (-not $body.newPassword) { throw 'newPassword is required' }
if (-not $body.requestedBy) { throw 'requestedBy is required (audit field)' }
# Refuse to touch privileged groups
$user = Get-ADUser -Identity $body.samAccountName -Properties MemberOf
$denyGroups = @('Domain Admins','Enterprise Admins','Schema Admins')
foreach ($g in $user.MemberOf) {
$name = ($g -split ',')[0] -replace '^CN='
if ($denyGroups -contains $name) {
throw "refusing to reset password for member of $name"
}
}
$secure = ConvertTo-SecureString $body.newPassword -AsPlainText -Force
Set-ADAccountPassword -Identity $user -NewPassword $secure -Reset
Set-ADUser -Identity $user -ChangePasswordAtLogon $true
# Audit line goes to the webhook log automatically (return value becomes stdout).
"reset $($user.SamAccountName) requested by $($body.requestedBy)"
```
## Endpoint configuration
| Section | Setting | Value |
|---|---|---|
| Identity | Slug | `ad-reset` |
| Auth | Mode | **HMAC** with a strong secret shared with the help-desk app |
| Auth | HMAC header | `X-Signature-256` |
| Auth | HMAC prefix | `sha256=` |
| Auth | HMAC encoding | hex |
| Allowed clients | | `10.50.10.20` *(the help-desk app's IP only)* |
| Executor | Type | Windows PowerShell |
| Executor | Script path | `C:\Scripts\ad-password-reset.ps1` |
| Data passing | JSON body to stdin | ✓ |
| Data passing | Headers/query as env vars | ✗ |
| Run as | Identity | **Service** *(uses the gMSA)* |
| Response | Mode | Sync |
| Response | Timeout (sec) | 30 |
| Response | Fail on non-zero exit | ✓ |
## Calling it
```powershell
$body = @{
samAccountName = 'jdoe'
newPassword = 'TempP@ssw0rd!2026'
requestedBy = 'helpdesk_user@contoso.local'
} | ConvertTo-Json
$bytes = [Text.Encoding]::UTF8.GetBytes($body)
$hmac = [Security.Cryptography.HMACSHA256]::new(
[Text.Encoding]::UTF8.GetBytes('your-shared-secret'))
$sig = ([BitConverter]::ToString($hmac.ComputeHash($bytes)) -replace '-','').ToLower()
Invoke-RestMethod -Method POST `
-Uri 'http://webhooks.contoso.local:8080/hook/ad-reset' `
-Headers @{ 'X-Signature-256' = "sha256=$sig" } `
-ContentType 'application/json' -Body $body
```
## Operational notes
**Audit log**: every call lands in `C:\ProgramData\WebhookServer\logs\webhook-YYYYMMDD.log` with one line per run including the runId, slug, caller IP, exit code, and the script's stdout (the `"reset jdoe requested by helpdesk_user"` line). Ship those logs to your SIEM via the usual file-collector flow.
**Rotating the HMAC secret**: edit the endpoint in the GUI, replace the secret, save. The help-desk app needs the new secret too — coordinate the cutover. There's no overlap window built in; if you need a soft rollover, create a second endpoint with the new secret and switch caller traffic over.
**Privileged-group guard**: the script's `denyGroups` check is a basic guard. If a more sophisticated guard is needed (target user attribute, OU-based logic), add it in the script — that's the right place, not the webhook server.
**Self-service from the user side**: don't expose this endpoint to end users directly. Front it with a help-desk app that authenticates the user (preferably with MFA), then makes the call to the webhook with its bearer/HMAC credentials. The webhook server is the *plumbing*; not the *front door*.
+129 -152
View File
@@ -1,98 +1,94 @@
# Recipe: Zerto failover post-script → DNS update + service checks
# Recipe: Zerto pre/post scripts AD / DNS update
This is the canonical reason Webhook Server exists.
This is the canonical reason Webhook Server exists. Zerto's failover, move, and clone operations support pre- and post-scripts — but those scripts run on the Zerto Virtual Manager (ZVM), not on the destination domain controller or DNS server. To touch AD or DNS during a failover you need either:
When Zerto fails a VM over from production to DR, the VM boots fine — but **the things around it** often need attention: DNS records still point at the production IP, dependent services need to be checked, on-call needs a heads-up. Zerto pre/post scripts run on the **Zerto Virtual Manager**, not on a domain controller and not necessarily with admin rights to the things that need fixing. So you want a single webhook URL that the post-script hits, and a Windows host on the DR side that does the actual work with the right identity.
- A bastion / utility host with the right modules and credentials installed (and you accept the maintenance burden of keeping its scripts in sync)
- **A webhook on a Windows host** — Zerto's pre/post calls a single URL, and the webhook server runs the right PowerShell on the right machine with the right identity. This page is about that.
## What we're building
Zerto's post-recovery script (a one-shot PowerShell file pointing at curl) calls `http://webhook.dr.contoso.local:8080/hook/post-failover` with a JSON body identifying the VPG and operation. The Webhook Server, running on a DR-side Windows host as a gMSA with delegated AD/DNS rights, runs PowerShell that:
A Zerto pre/post script POSTs to `http://webhooks.contoso.local:8080/hook/dr-failover-prep` with a JSON body identifying the VPG and target VMs. The webhook server, running on a domain-joined utility host as a gMSA with delegated AD rights, runs PowerShell that:
1. Updates DNS A records to point the failed-over hostnames at their DR IPs
2. Waits for the failed-over VM to come up (ping + WinRM probe)
3. Connects to the VM via PowerShell remoting and starts/checks critical services
4. Sends a Teams notification with the result
1. Updates AD computer object descriptions to indicate they're now at the DR site
2. Updates DNS A records to point `app01.contoso.local` and friends at the new (DR) IPs
3. Posts a result line to a Teams channel
4. Returns 200 with the summary so it shows up in Zerto's pre/post script log
The endpoint is **Async** so the Zerto script returns in milliseconds — no risk of timing out Zerto's failover sequence even if the actions take minutes. The script's full output ends up in the webhook log and (optionally) in an outbound callback.
It's about ~30 lines of PowerShell on the server side and 3 lines of script in Zerto.
## Why curl and not Invoke-WebRequest?
## Prerequisites
Zerto's PowerShell runner is intentionally minimal — many environments run an older Windows on the ZVM and don't have full PowerShell modules installed. `curl.exe` ships with Windows 10 1803+ and Server 2019+ and works without any modules. Plus, calling an HTTP endpoint with `curl.exe` doesn't depend on the version of `Invoke-WebRequest` shipped with the host's PowerShell.
On the webhook host:
## 1. The Zerto post-script (client side)
- Webhook Server installed (see [Installation](../installation.md))
- The host is domain-joined
- The service account has the **AD permissions** it needs. We'll configure this two ways below — the simple way (LocalSystem + delegated rights to the machine account) and the production way (gMSA).
- DNS PowerShell module installed if you'll modify DNS: `Install-WindowsFeature RSAT-DNS-Server` (Server) or RSAT installed (Win 10/11).
- AD PowerShell module: `Install-WindowsFeature RSAT-AD-PowerShell` (Server).
A ready-to-use script ships in this repo at [`scripts/examples/zerto-post-failover.ps1`](../../scripts/examples/zerto-post-failover.ps1). Copy it to the ZVM, edit `$WebhookUrl` and the bearer-token path at the top, and wire it into the VPG:
On the Zerto side:
> **VPG settings → Recovery → Scripts → Post-Recovery Script**
> Path: `C:\Scripts\zerto-post-failover.ps1`
> Parameters: *(leave empty)*
- ZVM 8.x or 9.x (this works with both)
- A Virtual Protection Group (VPG) you want to wire up
The script is ~50 lines and only depends on `curl.exe` + a token file readable by the ZVM service account.
## 1. Plan the script and the inputs
The flow:
What does the script need to know? At minimum:
```
Zerto VPG failover starts
|
+-- VM is brought up at DR site
|
+-- Zerto post-script fires:
| curl POST http://webhook.dr/hook/post-failover (async, returns 202 in ~50ms)
|
+-- Zerto sees success, finishes the failover and reports done
|
(meanwhile, on the webhook server)
|
running PowerShell for several minutes:
- update DNS
- wait for VM ready
- check services on VM
- notify Teams
- **VPG name** — Zerto exposes this as a parameter to the pre/post script
- **VM names** — likewise
- **Target IPs** — depending on your failover topology, these may be static (DR network has known IPs) or known after Zerto reconfigures the IP
Decide what travels in the request body and what's hardcoded. A pragmatic split:
- Hardcoded (in the PowerShell script on the webhook host): zone name, AD OU, Teams webhook URL, mapping table from VM hostname → target IP
- Sent in the body: VPG name, list of VM names, an "operation" field (`failover`, `move`, `failback`, etc.)
Example body the Zerto script will send:
```json
{
"operation": "failover",
"vpg": "App-Production",
"vms": ["app01", "app02", "db01"]
}
```
## 2. The server-side script (does the actual work)
## 2. Write the PowerShell script on the webhook host
Save this on the webhook host as `C:\Scripts\post-failover-handler.ps1`:
Save this as `C:\Scripts\dr-failover-prep.ps1` on the webhook host:
```powershell
[CmdletBinding()]
param()
$ErrorActionPreference = 'Stop'
# Read the body from stdin (the webhook server pipes the JSON in for us when
# StdinJson is enabled).
$body = $input | ConvertFrom-Json
# ---------- environment specifics; edit for your site ----------
# Hardcoded site config - edit for your environment.
$dnsServer = 'dc01.contoso.local'
$forwardZone = 'contoso.local'
$teamsWebhook = 'https://contoso.webhook.office.com/...'
$adOu = 'OU=Servers,DC=contoso,DC=local'
$teamsWebhook = 'https://contoso.webhook.office.com/...' # one-way, no secret to leak
$drIpMap = @{
'app01' = '10.42.10.11'
'app02' = '10.42.10.12'
'db01' = '10.42.10.21'
}
$serviceMap = @{
'app01' = @('W3SVC','MyAppSvc')
'app02' = @('W3SVC','MyAppSvc')
'db01' = @('MSSQLSERVER','SQLAgent')
}
# ---------------------------------------------------------------
# Default the VM list to "all VMs we know about" if the post-script didn't
# tell us, so the same handler works without having to embed the VM list in
# every Zerto post-script.
$vms = if ($body.vms) { $body.vms } else { $drIpMap.Keys }
$summary = @()
foreach ($vm in $vms) {
foreach ($vm in $body.vms) {
if (-not $drIpMap.ContainsKey($vm)) {
$summary += "skip $vm (no DR IP mapping in handler)"
$summary += "skip $vm - no DR IP mapping"
continue
}
$ip = $drIpMap[$vm]
$newIp = $drIpMap[$vm]
# 1. DNS - delete + re-add the A record
try {
# 1. Update DNS A record (delete + recreate is the simplest reliable path)
$existing = Get-DnsServerResourceRecord -ZoneName $forwardZone -Name $vm `
-RRType A -ComputerName $dnsServer -ErrorAction SilentlyContinue
if ($existing) {
@@ -101,143 +97,124 @@ foreach ($vm in $vms) {
-ComputerName $dnsServer -Force
}
Add-DnsServerResourceRecordA -ZoneName $forwardZone -Name $vm `
-IPv4Address $ip -ComputerName $dnsServer -TimeToLive 00:05:00
$summary += "dns $vm -> $ip"
} catch {
$summary += "DNS! $vm $($_.Exception.Message)"
continue
-IPv4Address $newIp -ComputerName $dnsServer -TimeToLive 00:05:00
# 2. Update AD computer description so on-call can see at a glance
Set-ADComputer -Identity $vm -Description "[DR-$($body.operation)] $(Get-Date -Format s)"
$summary += "ok $vm -> $newIp"
}
# 2. Wait for the VM to be reachable (up to 5 minutes)
$deadline = (Get-Date).AddMinutes(5)
$reachable = $false
while ((Get-Date) -lt $deadline) {
if (Test-Connection -ComputerName $ip -Count 1 -Quiet -ErrorAction SilentlyContinue) {
try {
# Quick WinRM probe; succeeds when the VM has finished booting
Invoke-Command -ComputerName $ip -ScriptBlock { $true } -ErrorAction Stop | Out-Null
$reachable = $true
break
} catch { Start-Sleep -Seconds 10 }
} else {
Start-Sleep -Seconds 10
}
}
if (-not $reachable) {
$summary += "wait! $vm not reachable after 5 minutes"
continue
}
# 3. Check + start critical services on the VM
if ($serviceMap.ContainsKey($vm)) {
$svcReport = Invoke-Command -ComputerName $ip -ArgumentList @(,$serviceMap[$vm]) -ScriptBlock {
param($services)
$report = @()
foreach ($s in $services) {
$svc = Get-Service -Name $s -ErrorAction SilentlyContinue
if (-not $svc) { $report += "$s : missing"; continue }
if ($svc.Status -ne 'Running') {
Start-Service $s
Start-Sleep -Seconds 2
$svc.Refresh()
}
$report += "$s : $($svc.Status)"
}
return $report
}
$summary += "svc $vm : $($svcReport -join ', ')"
} else {
$summary += "svc $vm (no services configured)"
}
}
# 4. Notify Teams
$teamsBody = @{
text = "Webhook post-failover for VPG **$($body.vpg)**:`n" + ($summary -join "`n")
# 3. Notify Teams
$msg = @{
text = "Webhook DR prep for VPG **$($body.vpg)** ($($body.operation)):`n" +
($summary -join "`n")
} | ConvertTo-Json
try {
Invoke-RestMethod -Uri $teamsWebhook -Method POST -ContentType 'application/json' -Body $teamsBody | Out-Null
} catch {
$summary += "teams! notification failed: $($_.Exception.Message)"
}
Invoke-RestMethod -Uri $teamsWebhook -Method POST -ContentType 'application/json' -Body $msg | Out-Null
# Return the summary so it shows up in the webhook log + outbound callback
# 4. Print the summary so Zerto's pre/post script log captures it
$summary -join "`n"
```
Two things to call out:
A few choices worth calling out:
- **PowerShell remoting to the VM** uses the gMSA's network identity (or whoever the service runs as). Make sure the gMSA / service account can `Invoke-Command` to the failed-over hosts — usually that means the account is a local admin on the target VMs, or you've configured constrained delegation.
- **WinRM** must be enabled on the failed-over VMs for the remoting calls to work. `Enable-PSRemoting` is the simplest, but most prod environments configure WinRM via Group Policy.
- **`$input | ConvertFrom-Json`** — Webhook Server pipes the request body into the script via stdin when "JSON body to stdin" is ticked. `$input` is PowerShell's automatic variable for pipeline input.
- **`$ErrorActionPreference = 'Stop'`** — turn cmdlet warnings into terminating errors so the script exits non-zero on real problems. Webhook Server then returns 502 (configurable via "Fail on non-zero exit") and Zerto sees the failure.
- **Two-way Teams notification but one-way return** — the script's stdout becomes the HTTP response. Zerto logs it. The Teams notification is a separate Invoke-RestMethod.
## 3. Configure the endpoint in the GUI
**File → New endpoint:**
In Webhook Server's GUI, **File → New endpoint**:
| Section | Setting | Value |
|---|---|---|
| Identity | Slug | `post-failover` |
| Identity | Description | "Zerto post-recovery: DNS + service checks" |
| Identity | Slug | `dr-failover-prep` |
| Identity | Description | "Zerto pre-script: update AD/DNS during failover" |
| Auth | Mode | **Bearer** |
| Auth | Bearer secret | generate a 32-byte random string; copy it for the Zerto script's token file |
| Allowed clients | (one per line) | `10.0.0.0/8` *(your ZVM's network)* |
| Auth | Bearer secret | generate a 32-byte random string; copy it for the Zerto script |
| Allowed clients | (one per line) | `10.0.0.0/8` (your ZVM's network) |
| Executor | Type | **Windows PowerShell** |
| Executor | Script path | `C:\Scripts\post-failover-handler.ps1` |
| Executor | Script path | `C:\Scripts\dr-failover-prep.ps1` |
| Data passing | JSON body to stdin | ✓ |
| Run as | Identity | **Service** if the service runs under a gMSA with the right rights, otherwise **SpecificUser** with a delegated account |
| Response | Mode | **Async** ← critical: this is what makes the Zerto script non-blocking |
| Response | Timeout (sec) | `600` *(this is the cap on the long-running handler script, not the Zerto-facing response)* |
| Response | Fail on non-zero exit | unticked *(async hooks have no caller to receive a 502)* |
| Data passing | Headers/query as env vars | ✗ |
| Run as | Identity | **Service** if the service is running as a gMSA with AD rights, otherwise **SpecificUser** with a delegated account |
| Response | Mode | **Sync** |
| Response | Timeout (sec) | `60` |
| Response | Fail on non-zero exit | ✓ |
Save. Right-click the row → **Copy URL** to grab `http://webhook.dr.contoso.local:8080/hook/post-failover` and paste it into `$WebhookUrl` at the top of the Zerto-side script.
Save. Right-click the row → **Copy URL** to grab the full URL, e.g. `http://webhooks.contoso.local:8080/hook/dr-failover-prep`.
> **Why Bearer instead of HMAC?** Both work. Bearer is simpler — drop the token in a file on the ZVM that's readable by the ZVM service account and you're done. HMAC requires the Zerto-side script to compute a signature, which is doable but adds a few lines of code. Pick what fits your environment.
> **Why Bearer auth and not None?** Even though the IP allowlist limits who can reach this endpoint, the Bearer token is a defense-in-depth layer. If someone managed to spoof or get on the trusted network, they still need the token. Generate it once, store it in a secrets manager (or in Zerto's encrypted script parameters), and never email it.
## 4. Wire up the bearer token
## 4. The Zerto pre/post script
Place the bearer token in a file the ZVM service account can read (and nobody else):
Zerto pre/post scripts are PowerShell files placed on the ZVM. The path varies by Zerto version; in 9.x it's typically `C:\Program Files\Zerto\Zerto Virtual Replication\Scripts\`.
Create `dr-failover-prep.ps1` on the ZVM:
```powershell
# on the ZVM, from elevated PowerShell
$token = (New-Guid).ToString('N') # or paste the value from the GUI
$tokenPath = 'C:\ProgramData\Zerto\webhook-token.txt'
$token | Out-File -LiteralPath $tokenPath -Encoding utf8 -NoNewline
icacls $tokenPath /inheritance:r /grant 'NT SERVICE\Zerto Online Services:R' 'BUILTIN\Administrators:F' /T
# Zerto passes context as parameters/environment - exact names vary by version.
# Document yours; this is illustrative.
param(
[string]$VpgName = $env:ZertoVPGName
)
$webhookUrl = 'http://webhooks.contoso.local:8080/hook/dr-failover-prep'
$bearer = 'paste-the-bearer-secret-here' # store via Zerto secret param if available
# Build the body. In a real script, list the VMs by querying Zerto's API or by
# convention from the VPG name.
$body = @{
operation = 'failover'
vpg = $VpgName
vms = @('app01','app02','db01')
} | ConvertTo-Json
$response = Invoke-RestMethod -Method POST -Uri $webhookUrl -Body $body `
-ContentType 'application/json' -TimeoutSec 90 `
-Headers @{ Authorization = "Bearer $bearer" }
# Print whatever the webhook returned to Zerto's log.
$response.stdout
```
Adjust the service principal name to whatever Zerto runs as on your version. The script reads from this path automatically; no change needed in the script itself.
Wire this script into your VPG's **Pre-Recovery** or **Post-Recovery** hook in the Zerto UI.
## 5. Test before going live
In a maintenance window, fire the webhook by hand:
In a maintenance window, hit the endpoint manually with a fake VPG name to confirm the wiring works:
```powershell
# from any machine that can reach the webhook server
$body = @{
operation = 'test'
vpg = 'SmokeTest'
timestamp = (Get-Date).ToUniversalTime().ToString('o')
} | ConvertTo-Json -Compress
curl.exe --silent --show-error --max-time 10 -X POST `
-H "Authorization: Bearer paste-the-token" `
-H "Content-Type: application/json" `
-d $body `
http://webhook.dr.contoso.local:8080/hook/post-failover
$body = @{ operation='test'; vpg='SmokeTest'; vms=@('app01') } | ConvertTo-Json
Invoke-RestMethod -Method POST `
-Uri http://webhooks.contoso.local:8080/hook/dr-failover-prep `
-Headers @{ Authorization = "Bearer paste-the-secret" } `
-ContentType application/json -Body $body
```
You'll get back `{"runId":"…","accepted":true}` immediately. Open the Webhook Server GUI and watch the log panel — within 30 seconds or so you'll see lines for the run. Confirm DNS records updated, services on each VM ended in `Running`, and the Teams notification arrived.
You should see the summary line(s) come back, AD descriptions update, DNS A records update, and a Teams notification. If anything's off:
- **No response, hang** → check the GUI's log panel. The auto-poll updates every 3 seconds. Look for the run line with the slug + exit code.
- **401 Unauthorized** → bearer mismatch
- **403 Forbidden** → IP allowlist blocking you
- **502 Bad Gateway** → script ran but exited non-zero. The response body has stderr.
After a real failover triggers it, audit by checking the daily log file at `C:\ProgramData\WebhookServer\logs\webhook-YYYYMMDD.log` for the `Run <id> dr-failover-prep ok exit=0` line.
## Variations
### Different actions for failover vs. failback
Pass an `operation` field in the body and branch on it. The Zerto-side script already sends `operation = 'failover'`. Add a separate post-failback script (or detect from `$env:ZertoOperationType`) that sends `operation = 'failback'` and have the handler revert DNS to production IPs.
Pass an `operation` field in the body and branch on it in the PowerShell. The script above already does this — extend the `switch` to handle `failback` (revert DNS to production IPs, clear DR description, etc.).
### Per-VPG endpoints
If you want fine-grained access control or different actions per VPG, create one endpoint per VPG (`post-failover-app`, `post-failover-db`, …) and give each its own bearer token. The GUI handles dozens of endpoints fine.
If you want fine-grained access control per VPG, create one endpoint per VPG and give each its own bearer secret. The GUI's grid handles dozens of endpoints fine.
### Async + callback for long-running work
If your AD/DNS update genuinely takes minutes (e.g., updating thousands of records in a large environment), set the endpoint to **Async** mode. Zerto's pre-script gets `202 Accepted` immediately and continues. Configure the endpoint's **Callback** with a URL that records the result (e.g., another endpoint that logs to a file, or your monitoring system's API).
### Audit trail to a SIEM
Each endpoint can have an outbound **Callback** URL. Configure it with your SIEM's HTTP collector + an HMAC secret, and every run produces a JSON record with runId, exit code, duration, stdout, and stderr — perfect for compliance.
Configure each endpoint's **Callback** with your SIEM's HTTP collector URL + an HMAC secret. Every run produces a JSON record with runId, exit code, duration, stdout, and stderr — perfect for compliance audit logs.
+1 -7
View File
@@ -55,7 +55,6 @@ Source: "{#RepoRoot}publish\service\*"; DestDir: "{app}"; Flags: ignoreversion r
Source: "{#RepoRoot}publish\gui\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
Source: "{#RepoRoot}scripts\install-service.ps1"; DestDir: "{app}\scripts"; Flags: ignoreversion
Source: "{#RepoRoot}scripts\uninstall-service.ps1"; DestDir: "{app}\scripts"; Flags: ignoreversion
Source: "{#RepoRoot}scripts\examples\*"; DestDir: "{app}\scripts\examples"; Flags: ignoreversion recursesubdirs createallsubdirs
Source: "{#RepoRoot}README.md"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#RepoRoot}docs\*"; DestDir: "{app}\docs"; Flags: ignoreversion recursesubdirs createallsubdirs
Source: "{#RepoRoot}resources\webhook-server.ico"; DestDir: "{app}"; Flags: ignoreversion
@@ -70,14 +69,9 @@ Filename: "powershell.exe"; \
Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{app}\scripts\install-service.ps1"" -BinaryPath ""{app}\{#ServiceExeName}"""; \
StatusMsg: "Installing Windows Service..."; \
Flags: runhidden
; Post-install GUI launch. The GUI's app.manifest is requireAdministrator,
; so launching with shellexec (ShellExecute) honors the manifest and triggers
; a clean UAC prompt. Using plain CreateProcess via the default Run path
; would skip the manifest and result in an un-elevated GUI that cannot connect
; to the admin pipe.
Filename: "{app}\{#AppExeName}"; \
Description: "Launch {#AppName}"; \
Flags: postinstall nowait shellexec skipifsilent
Flags: postinstall nowait skipifsilent
[UninstallRun]
Filename: "powershell.exe"; \
+3 -46
View File
@@ -53,58 +53,15 @@ if ($LASTEXITCODE -ne 0) { throw 'service publish failed' }
-c $Configuration -r win-x64 --self-contained false -o $publishGui | Out-Host
if ($LASTEXITCODE -ne 0) { throw 'GUI publish failed' }
# 2. 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.
# 2. Compile installer.
$iscc = Find-InnoCompiler
$iss = Join-Path $repoRoot 'installer\webhook-server.iss'
$dist = Join-Path $repoRoot 'dist'
New-Item -ItemType Directory -Path $dist -Force | Out-Null
Write-Host "Compiling installer with $iscc"
# Run ISCC from the .iss directory with just the bare filename. When invoked
# with a deeply-nested absolute path on the act-runner host (under
# %SystemRoot%\System32\config\systemprofile\...), ISCC sometimes prints a
# generic "The system cannot find the path specified." before it touches any
# source files. cd-ing first sidesteps it.
$issDir = Split-Path $iss -Parent
$issName = Split-Path $iss -Leaf
Push-Location $issDir
try {
Write-Host " cwd=$issDir"
& $iscc "/DAppVersion=$version" $issName
$exit = $LASTEXITCODE
} finally {
Pop-Location
}
if ($exit -ne 0) { throw "Inno Setup compile failed (exit $exit)" }
& $iscc "/DAppVersion=$version" $iss
if ($LASTEXITCODE -ne 0) { throw 'Inno Setup compile failed' }
$out = Get-Item (Join-Path $dist "WebhookServer-Setup-$version.exe")
Write-Host ""
-78
View File
@@ -1,78 +0,0 @@
<#
.SYNOPSIS
Zerto post-failover script. Fires the on-prem Webhook Server which does
the real work (DNS updates, service health checks, notifications).
.DESCRIPTION
Designed to be dropped into a Zerto VPG's post-recovery script slot. The
Zerto Virtual Manager's PowerShell runner has a limited module set and
runs scripts synchronously, so this script:
- uses curl.exe (ships with Windows 10 1803+ / Server 2019+) instead
of any module-dependent HTTP client;
- calls an ASYNC webhook endpoint - the server returns 202 in
milliseconds and runs the actual work in the background;
- returns within seconds regardless of how long the post-failover
actions take, so Zerto's failover sequence is never blocked.
Wire this into your VPG via the Zerto UI:
VPG settings -> Recovery -> Scripts -> Post-Recovery Script
Path: C:\path\to\zerto-post-failover.ps1
Parameters: leave empty (we read from $env:ZertoVPGName)
.NOTES
Configure $WebhookUrl and either:
- paste the bearer token directly into $Bearer (simplest, but the
token then lives in this file), or
- point $BearerFile at a file readable only by the ZVM service
account (better - same threat model as Zerto's own credential
storage).
#>
$ErrorActionPreference = 'Stop'
# ----------------------------- CONFIGURE ---------------------------------
$WebhookUrl = 'http://webhook.contoso.local:8080/hook/post-failover'
$Bearer = '' # paste here, or use $BearerFile
$BearerFile = 'C:\ProgramData\Zerto\webhook-token.txt' # one line: the token
# -------------------------------------------------------------------------
if (-not $Bearer -and (Test-Path $BearerFile)) {
$Bearer = (Get-Content -LiteralPath $BearerFile -TotalCount 1).Trim()
}
if (-not $Bearer) {
throw "No bearer token. Set `$Bearer in this script or write the token to $BearerFile."
}
# Compose the payload. Zerto exposes a few env vars; fall back gracefully.
$payload = @{
operation = 'failover'
vpg = if ($env:ZertoVPGName) { $env:ZertoVPGName } else { 'unknown' }
timestamp = (Get-Date).ToUniversalTime().ToString('o')
} | ConvertTo-Json -Compress
# curl on Windows handles long / quoted JSON better via @file than via -d "...".
$tempBody = Join-Path $env:TEMP ("zerto-webhook-{0}.json" -f ([guid]::NewGuid()))
$payload | Out-File -FilePath $tempBody -Encoding utf8 -NoNewline
try {
Write-Host "POST $WebhookUrl (vpg=$($env:ZertoVPGName))"
& curl.exe `
--silent --show-error --fail-with-body `
--max-time 10 `
-X POST `
-H "Authorization: Bearer $Bearer" `
-H "Content-Type: application/json" `
-d "@$tempBody" `
"$WebhookUrl"
if ($LASTEXITCODE -ne 0) {
# curl prints its own error to stderr; surface a non-zero exit so Zerto's
# script log records the failure but we don't block the failover.
Write-Warning "Webhook call failed with curl exit $LASTEXITCODE; continuing."
} else {
Write-Host "Webhook accepted (run id is in the response above)."
}
}
finally {
Remove-Item $tempBody -ErrorAction SilentlyContinue
}
-159
View File
@@ -1,159 +0,0 @@
<#
.SYNOPSIS
Mirrors the in-repo docs/ folder to a GitHub or Gitea wiki repo.
.DESCRIPTION
Wikis are separate git repositories (e.g. <repo>.wiki.git) with a flat URL
structure. This script:
1. Clones the wiki repo into a temp directory.
2. Wipes its existing .md content.
3. Copies each docs/*.md to a flattened wiki-style page name.
4. Rewrites in-repo markdown links so they point at the wiki page slugs.
5. Generates a _Sidebar.md so every wiki page has a navigation sidebar.
6. Commits and pushes back if anything changed.
Idempotent. Safe to re-run.
.PARAMETER WikiUrl
Full HTTPS URL to the wiki repo, including any embedded credentials. Examples:
https://github.com/recklessop/webhook-server.wiki.git
https://x-access-token:$TOKEN@github.com/recklessop/webhook-server.wiki.git
https://justin:$GITEA_TOKEN@git.jpaul.io/justin/webhook-server.wiki.git
.PARAMETER AuthorName
git committer name. Defaults to "Webhook Server Wiki Sync".
.PARAMETER AuthorEmail
git committer email. Defaults to "noreply@jpaul.me".
.EXAMPLE
# Manual sync to Gitea (token in env)
$env:GITEA_TOKEN = '...'
./scripts/sync-wiki.ps1 -WikiUrl "https://justin:$env:GITEA_TOKEN@git.jpaul.io/justin/webhook-server.wiki.git"
.EXAMPLE
# Manual sync to GitHub (gh-issued token)
$token = & gh auth token
./scripts/sync-wiki.ps1 -WikiUrl "https://x-access-token:$token@github.com/recklessop/webhook-server.wiki.git"
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$WikiUrl,
[string]$AuthorName = 'Webhook Server Wiki Sync',
[string]$AuthorEmail = 'noreply@jpaul.me'
)
# Continue (not Stop) because git writes informational messages to stderr
# (CRLF warnings, "remote: Processed N references" etc.) which PowerShell 5.1
# escalates to a script-fatal error under Stop. We check $LASTEXITCODE
# manually after each git call instead.
$ErrorActionPreference = 'Continue'
$repoRoot = Split-Path -Parent $PSScriptRoot
$docsDir = Join-Path $repoRoot 'docs'
$workDir = Join-Path $env:TEMP ("webhook-wiki-{0}" -f ([guid]::NewGuid().ToString('N').Substring(0, 8)))
# Source path (relative to docs/) -> wiki page slug. Order matters for the sidebar.
$mapping = [ordered]@{}
$mapping.Add('README.md', 'Home')
$mapping.Add('concepts.md', 'Concepts')
$mapping.Add('installation.md', 'Installation')
$mapping.Add('upgrading.md', 'Upgrading')
$mapping.Add('uninstalling.md', 'Uninstalling')
$mapping.Add('runas-modes.md', 'Run-As-Modes')
$mapping.Add('service-account-and-ad.md', 'Service-Account-and-AD')
$mapping.Add('network-and-security.md', 'Network-and-Security')
$mapping.Add('troubleshooting.md', 'Troubleshooting')
$mapping.Add('recipes/zerto-pre-post-scripts.md', 'Recipe-Zerto-Failover')
$mapping.Add('recipes/github-style-hmac.md', 'Recipe-GitHub-HMAC')
$mapping.Add('recipes/ui-on-desktop.md', 'Recipe-UI-on-Desktop')
function Rewrite-Links([string]$content) {
foreach ($m in $mapping.GetEnumerator()) {
# Match (path/to/file.md) and (path/to/file.md#anchor) inside markdown
# link parens. The lookbehind ensures we're consuming a real link target.
$escaped = [regex]::Escape($m.Key)
$content = [regex]::Replace($content,
"\(\.?\.?/?$escaped(\#[^)\s]*)?\)",
"($($m.Value)`$1)")
}
# Also clean up doubled prefixes like "../../docs/" or "../" pointers that
# sometimes appear in cross-folder relative links from docs/recipes/.
return $content
}
function New-Sidebar() {
$lines = @()
$lines += "[Home](Home)"
$lines += ""
$lines += "## Topical"
foreach ($key in @('concepts.md','installation.md','upgrading.md','uninstalling.md','runas-modes.md','service-account-and-ad.md','network-and-security.md','troubleshooting.md')) {
$slug = $mapping[$key]
$lines += "- [$($slug -replace '-', ' ')]($slug)"
}
$lines += ""
$lines += "## Recipes"
foreach ($key in @('recipes/zerto-pre-post-scripts.md','recipes/github-style-hmac.md','recipes/ui-on-desktop.md')) {
$slug = $mapping[$key]
$lines += "- [$($slug -replace '^Recipe-' -replace '-', ' ')]($slug)"
}
return ($lines -join "`n")
}
# 1. Clone the wiki.
Write-Host "Cloning wiki to $workDir..."
& git clone --quiet $WikiUrl $workDir 2>&1 | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "git clone failed. Has the wiki been initialized? Visit the repo's Wiki tab and create the first page via the UI before running this script."
}
# Suppress git's CRLF nags for this throwaway clone so they don't become
# "errors" via PowerShell's native-command stderr handling.
& git -C $workDir config core.autocrlf false 2>&1 | Out-Null
& git -C $workDir config core.safecrlf false 2>&1 | Out-Null
try {
Push-Location $workDir
try {
# 2. Wipe existing markdown so removed source files vanish from the wiki.
Get-ChildItem -Filter "*.md" -Force | Remove-Item -Force
# 3. Copy + transform each source file.
$written = 0
foreach ($entry in $mapping.GetEnumerator()) {
$src = Join-Path $docsDir $entry.Key
$dst = Join-Path $workDir "$($entry.Value).md"
if (-not (Test-Path $src)) {
Write-Warning "Source missing, skipping: $src"
continue
}
$content = Get-Content -LiteralPath $src -Raw
$content = Rewrite-Links $content
Set-Content -LiteralPath $dst -Value $content -Encoding utf8 -NoNewline
$written++
}
Write-Host "Wrote $written markdown pages."
# 4. Sidebar
Set-Content -LiteralPath (Join-Path $workDir '_Sidebar.md') -Value (New-Sidebar) -Encoding utf8 -NoNewline
# 5. Commit + push if anything actually changed. Drain stderr from each
# git invocation so PowerShell doesn't treat warnings as errors.
& git add -A 2>&1 | Out-Null
$changes = & git status --porcelain 2>&1
if (-not $changes) {
Write-Host "Wiki already up to date."
return
}
$sha = & git -C $repoRoot rev-parse --short HEAD 2>&1
& git -c "user.name=$AuthorName" -c "user.email=$AuthorEmail" commit -q -m "Sync from docs/ at $sha" 2>&1 | Out-Null
if ($LASTEXITCODE -ne 0) { throw "git commit failed (exit $LASTEXITCODE)" }
& git push --quiet 2>&1 | Out-Null
if ($LASTEXITCODE -ne 0) { throw "git push failed (exit $LASTEXITCODE)" }
Write-Host "Pushed updated wiki."
}
finally { Pop-Location }
}
finally {
Remove-Item -Recurse -Force $workDir -ErrorAction SilentlyContinue
}
@@ -26,7 +26,6 @@ public static class AdminOps
public const string ListBackups = "list-backups";
public const string RestoreBackup = "restore-backup";
public const string ImportConfig = "import-config";
public const string CreateCheckpoint = "create-checkpoint";
}
public sealed class BackupEntry
@@ -34,7 +33,6 @@ public sealed class BackupEntry
public string FileName { get; set; } = "";
public DateTimeOffset SavedAt { get; set; }
public long SizeBytes { get; set; }
public string? Description { get; set; }
}
public sealed class RestoreBackupArgs
@@ -42,11 +40,6 @@ public sealed class RestoreBackupArgs
public string FileName { get; set; } = "";
}
public sealed class CreateCheckpointArgs
{
public string? Description { get; set; }
}
public sealed class AdminRequest
{
[JsonPropertyName("op")] public string Op { get; set; } = "";
+2 -16
View File
@@ -48,15 +48,8 @@ public sealed class ConfigStore
Directory.CreateDirectory(backupsDir);
var stamp = DateTime.UtcNow.ToString("yyyyMMdd-HHmmss");
var backupPath = System.IO.Path.Combine(backupsDir, $"config-{stamp}.json");
if (!File.Exists(backupPath))
{
File.Copy(Path, backupPath, overwrite: false);
var sidecar = new { description = "Before save", reason = "before-save" };
File.WriteAllText(
System.IO.Path.ChangeExtension(backupPath, ".meta.json"),
JsonSerializer.Serialize(sidecar, ConfigJson.Compact));
}
PruneBackups(backupsDir, retain: 90);
PruneBackups(backupsDir, retain: 30);
}
catch
{
@@ -78,18 +71,11 @@ public sealed class ConfigStore
private static void PruneBackups(string backupsDir, int retain)
{
var stale = new DirectoryInfo(backupsDir).GetFiles("config-*.json")
.Where(f => !f.Name.EndsWith(".meta.json", StringComparison.OrdinalIgnoreCase))
.OrderByDescending(f => f.Name)
.Skip(retain);
foreach (var f in stale)
{
try
{
f.Delete();
var sidecar = System.IO.Path.ChangeExtension(f.FullName, ".meta.json");
if (File.Exists(sidecar)) File.Delete(sidecar);
}
catch { }
try { f.Delete(); } catch { }
}
}
+24 -20
View File
@@ -29,12 +29,25 @@
<Separator/>
<MenuItem Header="_Import config…" Command="{Binding ImportConfigCommand}"/>
<MenuItem Header="_Export config…" Command="{Binding ExportConfigCommand}"/>
<MenuItem Header="Config _Checkpoints…" Command="{Binding ShowConfigCheckpointsCommand}"/>
<Separator/>
<MenuItem Header="_Minimize to tray"
IsCheckable="True"
IsChecked="{Binding MinimizeToTrayEnabled, Mode=TwoWay}"
ToolTip="When ticked, closing or minimizing the window hides it to the tray and keeps the GUI process alive. Untick to make the X button quit the app."/>
<MenuItem Header="Config _Checkpoints"
ItemsSource="{Binding Backups}"
ToolTip="Snapshots taken automatically before each save. Click one to restore."
SubmenuOpened="OnBackupsSubmenuOpened">
<MenuItem.ItemContainerStyle>
<Style TargetType="MenuItem">
<Setter Property="Header">
<Setter.Value>
<MultiBinding StringFormat="{}{0:yyyy-MM-dd HH:mm:ss} ({1:n0} bytes)">
<Binding Path="SavedAt"/>
<Binding Path="SizeBytes"/>
</MultiBinding>
</Setter.Value>
</Setter>
<Setter Property="Command" Value="{Binding DataContext.RestoreBackupCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
<Setter Property="CommandParameter" Value="{Binding}"/>
</Style>
</MenuItem.ItemContainerStyle>
</MenuItem>
<Separator/>
<MenuItem Header="E_xit" Command="{Binding ExitCommand}"/>
</MenuItem>
@@ -68,26 +81,17 @@
<DataGrid.RowStyle>
<Style TargetType="DataGridRow">
<EventSetter Event="MouseDoubleClick" Handler="OnRowDoubleClick"/>
<!-- The ContextMenu lives in its own visual tree (a popup), so
AncestorType=Window doesn't resolve from inside menu items.
Stash MainViewModel on the row's Tag here (still in the
Window's tree), then reach it from the menu via
PlacementTarget.Tag. -->
<Setter Property="Tag" Value="{Binding DataContext, RelativeSource={RelativeSource AncestorType=Window}}"/>
<Setter Property="ContextMenu">
<Setter.Value>
<ContextMenu>
<MenuItem Header="_Edit…"
Command="{Binding PlacementTarget.Tag.EditEndpointCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"/>
<MenuItem Header="_Copy URL"
Command="{Binding PlacementTarget.Tag.CopyEndpointUrlCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"/>
<MenuItem Header="_Edit…" Command="{Binding DataContext.EditEndpointCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
<MenuItem Header="_Copy URL" Command="{Binding DataContext.CopyEndpointUrlCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
<Separator/>
<MenuItem Header="Toggle _enabled"
Command="{Binding PlacementTarget.Tag.ToggleEnabledCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
CommandParameter="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource AncestorType=ContextMenu}}"/>
Command="{Binding DataContext.ToggleEnabledCommand, RelativeSource={RelativeSource AncestorType=Window}}"
CommandParameter="{Binding}"/>
<Separator/>
<MenuItem Header="_Delete…"
Command="{Binding PlacementTarget.Tag.DeleteEndpointCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"/>
<MenuItem Header="_Delete…" Command="{Binding DataContext.DeleteEndpointCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
</ContextMenu>
</Setter.Value>
</Setter>
+10 -36
View File
@@ -1,4 +1,3 @@
using System.ComponentModel;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
@@ -12,56 +11,26 @@ public partial class MainWindow : Window
private readonly TrayIcon _tray;
private readonly MainViewModel _vm;
/// <summary>
/// Set to true when the user has explicitly asked to quit (File -> Exit or
/// Tray -> Exit). The OnClosing handler reads this to decide whether to
/// actually let the window close or hide it to the tray.
/// </summary>
public bool ExitForReal { get; set; }
public MainWindow()
{
InitializeComponent();
_vm = new MainViewModel(new AdminPipeClient());
DataContext = _vm;
_vm.RealExitRequested += OnRealExitRequested;
_tray = new TrayIcon(
resolveMainWindow: () => Application.Current.MainWindow,
restartServiceAsync: async () => await new AdminPipeClient().RestartListenerAsync(),
onExit: OnRealExitRequested);
restartServiceAsync: async () => await new AdminPipeClient().RestartListenerAsync());
Loaded += async (_, _) => await _vm.RefreshCommand.ExecuteAsync(null);
StateChanged += OnStateChanged;
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();
Closed += (_, _) => _tray.Dispose();
}
private void OnStateChanged(object? sender, EventArgs e)
{
// Minimize-to-tray: hide the window when the user minimizes IF they've
// opted in via File -> Minimize to tray. Otherwise behave like a normal
// Windows minimize.
if (WindowState == WindowState.Minimized && _vm.MinimizeToTrayEnabled)
// Minimize-to-tray: hide the window when the user minimizes; restoring is
// via the tray icon's double-click or context menu.
if (WindowState == WindowState.Minimized)
{
Hide();
ShowInTaskbar = false;
@@ -84,4 +53,9 @@ public partial class MainWindow : Window
vm.EditEndpointCommand.Execute(null);
}
private async void OnBackupsSubmenuOpened(object sender, RoutedEventArgs e)
{
if (DataContext is MainViewModel vm)
await vm.RefreshBackupsCommand.ExecuteAsync(null);
}
}
@@ -100,7 +100,4 @@ public sealed class AdminPipeClient
public Task<AdminResponse> ImportConfigAsync(ServerConfig config, CancellationToken ct = default) =>
InvokeAsync(AdminOps.ImportConfig, config, ct);
public Task<BackupEntry?> CreateCheckpointAsync(string? description, CancellationToken ct = default) =>
InvokeAsync<BackupEntry>(AdminOps.CreateCheckpoint, new CreateCheckpointArgs { Description = description }, ct);
}
@@ -1,50 +0,0 @@
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 */ }
}
}
+2 -4
View File
@@ -16,13 +16,11 @@ public sealed class TrayIcon : IDisposable
private readonly NotifyIcon _icon;
private readonly Func<Window?> _resolveMainWindow;
private readonly Func<Task> _restartServiceAsync;
private readonly Action _onExit;
public TrayIcon(Func<Window?> resolveMainWindow, Func<Task> restartServiceAsync, Action onExit)
public TrayIcon(Func<Window?> resolveMainWindow, Func<Task> restartServiceAsync)
{
_resolveMainWindow = resolveMainWindow;
_restartServiceAsync = restartServiceAsync;
_onExit = onExit;
_icon = new NotifyIcon
{
@@ -41,7 +39,7 @@ public sealed class TrayIcon : IDisposable
menu.Items.Add(new ToolStripSeparator());
menu.Items.Add("&Restart service", null, async (_, _) => await _restartServiceAsync().ConfigureAwait(false));
menu.Items.Add(new ToolStripSeparator());
menu.Items.Add("E&xit", null, (_, _) => _onExit());
menu.Items.Add("E&xit", null, (_, _) => Application.Current.Shutdown());
return menu;
}
@@ -1,111 +0,0 @@
using System.Collections.ObjectModel;
using System.Runtime.Versioning;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using WebhookServer.Core.Ipc;
using WebhookServer.Gui.Services;
namespace WebhookServer.Gui.ViewModels;
[SupportedOSPlatform("windows")]
public sealed partial class ConfigCheckpointsViewModel : ObservableObject
{
private readonly AdminPipeClient _client;
public ObservableCollection<BackupEntry> Checkpoints { get; } = new();
[ObservableProperty] private BackupEntry? _selected;
[ObservableProperty] private string _statusMessage = "";
public ConfigCheckpointsViewModel(AdminPipeClient client)
{
_client = client;
}
[RelayCommand]
public async Task RefreshAsync()
{
try
{
var list = await _client.ListBackupsAsync().ConfigureAwait(false);
Application.Current.Dispatcher.Invoke(() =>
{
Checkpoints.Clear();
foreach (var b in list) Checkpoints.Add(b);
StatusMessage = list.Count == 0
? "No checkpoints yet. Save the config or click Take Checkpoint Now."
: $"{list.Count} checkpoint{(list.Count == 1 ? "" : "s")}.";
});
}
catch (Exception ex)
{
Application.Current.Dispatcher.Invoke(() => StatusMessage = $"Could not load: {ex.Message}");
}
}
[RelayCommand]
private async Task TakeCheckpointAsync()
{
// Prompt for an optional description on the UI thread.
string? description = null;
var prompted = Application.Current.Dispatcher.Invoke(() =>
{
var dlg = new Views.TakeCheckpointDialog { Owner = Application.Current.MainWindow };
if (dlg.ShowDialog() != true) return false;
description = string.IsNullOrWhiteSpace(dlg.Description) ? null : dlg.Description;
return true;
});
if (!prompted) return;
try
{
var entry = await _client.CreateCheckpointAsync(description).ConfigureAwait(false);
await RefreshAsync().ConfigureAwait(false);
if (entry is not null)
{
Application.Current.Dispatcher.Invoke(() =>
{
Selected = Checkpoints.FirstOrDefault(c => c.FileName == entry.FileName);
StatusMessage = $"Created {entry.FileName}";
});
}
}
catch (Exception ex)
{
Application.Current.Dispatcher.Invoke(() =>
MessageBox.Show(ex.Message, "Take checkpoint failed", MessageBoxButton.OK, MessageBoxImage.Error));
}
}
[RelayCommand]
private async Task RollbackAsync()
{
if (Selected is null) return;
// Capture before the refresh; the ObservableCollection.Clear() in
// RefreshAsync nulls Selected (the original instance is gone from the
// collection so the SelectedItem binding clears).
var fileName = Selected.FileName;
var savedAt = Selected.SavedAt;
var ok = MessageBox.Show(
$"Roll the configuration back to the checkpoint from {savedAt.ToLocalTime():yyyy-MM-dd HH:mm:ss}?\n\nThe current configuration is automatically saved as a new checkpoint first, so you can roll forward again.",
"Confirm rollback",
MessageBoxButton.OKCancel,
MessageBoxImage.Warning);
if (ok != MessageBoxResult.OK) return;
try
{
await _client.RestoreBackupAsync(fileName).ConfigureAwait(false);
await RefreshAsync().ConfigureAwait(false);
Application.Current.Dispatcher.Invoke(() =>
StatusMessage = $"Rolled back to {fileName}.");
}
catch (Exception ex)
{
Application.Current.Dispatcher.Invoke(() =>
MessageBox.Show(ex.Message, "Rollback failed", MessageBoxButton.OK, MessageBoxImage.Error));
}
}
}
@@ -29,28 +29,17 @@ public sealed partial class MainViewModel : ObservableObject
[ObservableProperty] private ServerConfig _serverConfig = new();
[ObservableProperty] private string _httpBaseUrl = "http://localhost:8080";
[ObservableProperty] private string? _httpsBaseUrl;
[ObservableProperty] private bool _minimizeToTrayEnabled;
private readonly DispatcherTimer _logTimer;
private readonly GuiSettings _settings;
public MainViewModel(AdminPipeClient client)
{
_client = client;
_settings = GuiSettings.Load();
_minimizeToTrayEnabled = _settings.MinimizeToTrayEnabled;
_logTimer = new DispatcherTimer(DispatcherPriority.Background) { Interval = TimeSpan.FromSeconds(3) };
_logTimer.Tick += async (_, _) => await RefreshLogTailAsync();
_logTimer.Start();
}
partial void OnMinimizeToTrayEnabledChanged(bool value)
{
_settings.MinimizeToTrayEnabled = value;
_settings.Save();
}
[RelayCommand]
private async Task RefreshAsync()
{
@@ -186,18 +175,39 @@ public sealed partial class MainViewModel : ObservableObject
}
}
[ObservableProperty] private System.Collections.ObjectModel.ObservableCollection<BackupEntry> _backups = new();
[RelayCommand]
private void ShowConfigCheckpoints()
private async Task RefreshBackupsAsync()
{
var dlg = new Views.ConfigCheckpointsDialog
try
{
Owner = Application.Current.MainWindow,
DataContext = new ConfigCheckpointsViewModel(_client),
};
dlg.ShowDialog();
// After the dialog closes, the live config may have changed via rollback,
// so refresh the main grid.
_ = RefreshAsync();
var list = await _client.ListBackupsAsync().ConfigureAwait(false);
Application.Current.Dispatcher.Invoke(() =>
{
Backups.Clear();
foreach (var b in list) Backups.Add(b);
});
}
catch { /* ignore - checkpoint listing isn't critical */ }
}
[RelayCommand]
private async Task RestoreBackupAsync(BackupEntry? entry)
{
if (entry is null) return;
var ok = MessageBox.Show(
$"Restore the configuration from the checkpoint taken at {entry.SavedAt:yyyy-MM-dd HH:mm}?\n\nThe current configuration is automatically saved as a new checkpoint first, so you can roll forward again.",
"Restore checkpoint",
MessageBoxButton.OKCancel,
MessageBoxImage.Question);
if (ok != MessageBoxResult.OK) return;
try
{
await _client.RestoreBackupAsync(entry.FileName).ConfigureAwait(false);
await RefreshAsync().ConfigureAwait(false);
}
catch (Exception ex) { ShowError("Restore failed", ex); }
}
[RelayCommand]
@@ -297,14 +307,10 @@ public sealed partial class MainViewModel : ObservableObject
}
}
/// <summary>Raised when the user picks File -> Exit. MainWindow flips its
/// ExitForReal flag and shuts down, bypassing the X-hides-to-tray logic.</summary>
public event Action? RealExitRequested;
[RelayCommand]
private void Exit()
{
RealExitRequested?.Invoke();
Application.Current.Shutdown();
}
[RelayCommand]
@@ -1,53 +0,0 @@
<Window x:Class="WebhookServer.Gui.Views.ConfigCheckpointsDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="clr-namespace:WebhookServer.Gui.ViewModels"
mc:Ignorable="d"
Title="Config Checkpoints"
Height="500" Width="640"
Icon="/webhook-server.ico"
WindowStartupLocation="CenterOwner"
d:DataContext="{d:DesignInstance Type=vm:ConfigCheckpointsViewModel}">
<DockPanel Margin="12">
<TextBlock DockPanel.Dock="Top" TextWrapping="Wrap" Margin="0,0,0,8" Foreground="#444">
A checkpoint is a snapshot of <Bold>config.json</Bold> taken before each save and once a day at midnight.
Pick one and click <Bold>Roll Back</Bold> to restore it. The current configuration is automatically saved
as a new checkpoint before any rollback, so you can always roll forward again.
</TextBlock>
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,8,0,0">
<Button Content="Take Checkpoint Now" Command="{Binding TakeCheckpointCommand}" Margin="0,0,8,0" Padding="10,4"/>
<Button Content="Roll Back" Command="{Binding RollbackCommand}"
IsEnabled="{Binding Selected, Converter={StaticResource NotNull}}"
Margin="0,0,8,0" Padding="10,4"/>
<Button Content="Close" IsCancel="True" Click="OnClose" Padding="10,4"/>
</StackPanel>
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" Margin="0,4,0,0">
<Button Content="Refresh" Command="{Binding RefreshCommand}" Padding="8,2"/>
<TextBlock Text="{Binding StatusMessage}" Foreground="Gray" FontStyle="Italic" VerticalAlignment="Center" Margin="12,0,0,0"/>
</StackPanel>
<DataGrid ItemsSource="{Binding Checkpoints}"
SelectedItem="{Binding Selected, Mode=TwoWay}"
AutoGenerateColumns="False"
CanUserAddRows="False"
CanUserDeleteRows="False"
IsReadOnly="True"
HeadersVisibility="Column"
GridLinesVisibility="Horizontal">
<DataGrid.Columns>
<DataGridTextColumn Header="When (local)" Width="170"
Binding="{Binding SavedAt, StringFormat='{}{0:yyyy-MM-dd HH:mm:ss}', ConverterCulture=en-US}"/>
<DataGridTextColumn Header="Description" Width="*"
Binding="{Binding Description}"/>
<DataGridTextColumn Header="Size" Width="100"
Binding="{Binding SizeBytes, StringFormat='{}{0:n0} bytes'}"/>
<DataGridTextColumn Header="File name" Width="200"
Binding="{Binding FileName}" FontFamily="Consolas"/>
</DataGrid.Columns>
</DataGrid>
</DockPanel>
</Window>
@@ -1,19 +0,0 @@
using System.Windows;
using WebhookServer.Gui.ViewModels;
namespace WebhookServer.Gui.Views;
public partial class ConfigCheckpointsDialog : Window
{
public ConfigCheckpointsDialog()
{
InitializeComponent();
Loaded += async (_, _) =>
{
if (DataContext is ConfigCheckpointsViewModel vm)
await vm.RefreshAsync();
};
}
private void OnClose(object sender, RoutedEventArgs e) => Close();
}
@@ -1,34 +0,0 @@
<Window x:Class="WebhookServer.Gui.Views.TakeCheckpointDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Take checkpoint"
Height="180" Width="440"
ResizeMode="NoResize"
WindowStartupLocation="CenterOwner"
Icon="/webhook-server.ico"
ShowInTaskbar="False">
<Grid Margin="16">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" TextWrapping="Wrap"
Text="Description for this checkpoint (optional):"/>
<TextBox x:Name="DescriptionBox" Grid.Row="1" Margin="0,8,0,0" MaxLength="120">
<TextBox.InputBindings>
<KeyBinding Key="Enter" Command="{Binding OkCommand, ElementName=Self, FallbackValue={x:Null}}"/>
</TextBox.InputBindings>
</TextBox>
<TextBlock Grid.Row="2" Foreground="Gray" FontStyle="Italic" FontSize="11" Margin="0,4,0,0"
Text="Examples: 'Before adding new endpoint', 'Pre-AD-policy-change'. Leave blank to use 'Manual checkpoint'."/>
<StackPanel Grid.Row="4" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0">
<Button Content="OK" Width="80" IsDefault="True" Click="OnOk" Margin="0,0,8,0"/>
<Button Content="Cancel" Width="80" IsCancel="True" Click="OnCancel"/>
</StackPanel>
</Grid>
</Window>
@@ -1,27 +0,0 @@
using System.Windows;
namespace WebhookServer.Gui.Views;
public partial class TakeCheckpointDialog : Window
{
public string Description { get; private set; } = "";
public TakeCheckpointDialog()
{
InitializeComponent();
Loaded += (_, _) => DescriptionBox.Focus();
}
private void OnOk(object sender, RoutedEventArgs e)
{
Description = DescriptionBox.Text?.Trim() ?? "";
DialogResult = true;
Close();
}
private void OnCancel(object sender, RoutedEventArgs e)
{
DialogResult = false;
Close();
}
}
@@ -225,65 +225,16 @@ internal sealed class AdminPipeServer : BackgroundService
return AdminResponse.Success(SafeSnapshotForWire(_state.Snapshot()));
}
case AdminOps.CreateCheckpoint:
{
var args = DeserializeData<CreateCheckpointArgs>(request);
var description = args?.Description;
if (string.IsNullOrWhiteSpace(description)) description = "Manual checkpoint";
var entry = CreateCheckpoint("manual", description);
_logger.LogInformation("Manual checkpoint created: {File} ({Desc})", entry.FileName, description);
return AdminResponse.Success(entry);
}
default:
return AdminResponse.Failure($"unknown op '{request.Op}'");
}
}
/// <summary>
/// Snapshot the current config.json into the backups folder. Used by the
/// "Take checkpoint now" GUI action, the midnight scheduler, and the
/// auto-on-save hook in ConfigStore. Description is stored in a sidecar
/// .meta.json file next to the snapshot so it survives restarts and can
/// be rendered in the GUI.
/// </summary>
public static BackupEntry CreateCheckpoint(string reason, string description)
{
var configPath = ServicePaths.ConfigPath;
if (!File.Exists(configPath))
throw new FileNotFoundException("no config.json exists yet to snapshot");
var dir = Path.Combine(ServicePaths.DataRoot, "backups");
Directory.CreateDirectory(dir);
var stamp = DateTime.UtcNow.ToString("yyyyMMdd-HHmmss");
var dest = Path.Combine(dir, $"config-{stamp}.json");
if (File.Exists(dest))
dest = Path.Combine(dir, $"config-{stamp}-{reason}.json");
File.Copy(configPath, dest);
// Write the sidecar metadata.
var sidecarPath = Path.ChangeExtension(dest, ".meta.json");
var sidecar = new { description, reason };
File.WriteAllText(sidecarPath, JsonSerializer.Serialize(sidecar, ConfigJson.Compact));
var info = new FileInfo(dest);
return new BackupEntry
{
FileName = info.Name,
SavedAt = info.LastWriteTimeUtc,
SizeBytes = info.Length,
Description = description,
};
}
private static List<BackupEntry> ListBackups()
{
var dir = Path.Combine(ServicePaths.DataRoot, "backups");
if (!Directory.Exists(dir)) return new List<BackupEntry>();
return new DirectoryInfo(dir).GetFiles("config-*.json")
.Where(f => !f.Name.EndsWith(".meta.json", StringComparison.OrdinalIgnoreCase))
.OrderByDescending(f => f.Name)
.Take(50)
.Select(f => new BackupEntry
@@ -291,23 +242,10 @@ internal sealed class AdminPipeServer : BackgroundService
FileName = f.Name,
SavedAt = f.LastWriteTimeUtc,
SizeBytes = f.Length,
Description = ReadSidecarDescription(f.FullName),
})
.ToList();
}
private static string? ReadSidecarDescription(string snapshotPath)
{
try
{
var sidecarPath = Path.ChangeExtension(snapshotPath, ".meta.json");
if (!File.Exists(sidecarPath)) return null;
using var doc = JsonDocument.Parse(File.ReadAllText(sidecarPath));
return doc.RootElement.TryGetProperty("description", out var d) ? d.GetString() : null;
}
catch { return null; }
}
private async Task<ServerConfig> RestoreBackupAsync(string fileName, CancellationToken ct)
{
// Refuse anything that tries to escape the backups directory.
@@ -1,50 +0,0 @@
using System.Runtime.Versioning;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace WebhookServer.Service;
/// <summary>
/// Creates a daily config checkpoint at midnight (local time). Combined with
/// the auto-on-save snapshots in ConfigStore.SaveAsync, this guarantees a
/// rollback point for every day even if the user makes no changes.
/// </summary>
[SupportedOSPlatform("windows")]
internal sealed class CheckpointScheduler : BackgroundService
{
private readonly ILogger<CheckpointScheduler> _logger;
public CheckpointScheduler(ILogger<CheckpointScheduler> logger)
{
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Daily checkpoint scheduler running");
while (!stoppingToken.IsCancellationRequested)
{
var now = DateTime.Now;
var nextMidnight = now.Date.AddDays(1);
var delay = nextMidnight - now;
try { await Task.Delay(delay, stoppingToken).ConfigureAwait(false); }
catch (OperationCanceledException) { return; }
try
{
var entry = AdminPipeServer.CreateCheckpoint("daily", "Nightly auto-checkpoint");
_logger.LogInformation("Daily checkpoint created: {File}", entry.FileName);
}
catch (FileNotFoundException)
{
// No config.json yet (fresh install, GUI never opened) - skip silently.
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Daily checkpoint creation failed");
}
}
}
}
-1
View File
@@ -49,7 +49,6 @@ try
builder.Services.AddSingleton<WebhookRouter>();
builder.Services.AddHostedService<CallbackBackgroundService>();
builder.Services.AddHostedService<AdminPipeServer>();
builder.Services.AddHostedService<CheckpointScheduler>();
var app = builder.Build();