Compare commits
4 Commits
v0.1.3
..
c2ecb6b564
| Author | SHA1 | Date | |
|---|---|---|---|
| c2ecb6b564 | |||
| 0217d9f307 | |||
| baecc99b23 | |||
| c9c5bd37d2 |
@@ -1,27 +0,0 @@
|
||||
name: Sync Wiki
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'docs/**'
|
||||
- 'scripts/sync-wiki.ps1'
|
||||
- '.github/workflows/wiki-sync.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
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,7 +1,7 @@
|
||||
<Project>
|
||||
|
||||
<PropertyGroup>
|
||||
<Version>0.1.3</Version>
|
||||
<Version>0.1.1</Version>
|
||||
<Authors>Justin Paul</Authors>
|
||||
<Company>Justin Paul</Company>
|
||||
<Product>Webhook Server</Product>
|
||||
|
||||
@@ -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 scripts → AD / 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
@@ -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 scripts → AD / 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 scripts → AD / 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)
|
||||
|
||||
@@ -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*.
|
||||
@@ -1,243 +1,220 @@
|
||||
# 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 {
|
||||
$existing = Get-DnsServerResourceRecord -ZoneName $forwardZone -Name $vm `
|
||||
-RRType A -ComputerName $dnsServer -ErrorAction SilentlyContinue
|
||||
if ($existing) {
|
||||
Remove-DnsServerResourceRecord -ZoneName $forwardZone -Name $vm `
|
||||
-RRType A -RecordData $existing.RecordData.IPv4Address `
|
||||
-ComputerName $dnsServer -Force
|
||||
}
|
||||
Add-DnsServerResourceRecordA -ZoneName $forwardZone -Name $vm `
|
||||
-IPv4Address $ip -ComputerName $dnsServer -TimeToLive 00:05:00
|
||||
$summary += "dns $vm -> $ip"
|
||||
} catch {
|
||||
$summary += "DNS! $vm $($_.Exception.Message)"
|
||||
continue
|
||||
# 1. Update DNS A record (delete + recreate is the simplest reliable path)
|
||||
$existing = Get-DnsServerResourceRecord -ZoneName $forwardZone -Name $vm `
|
||||
-RRType A -ComputerName $dnsServer -ErrorAction SilentlyContinue
|
||||
if ($existing) {
|
||||
Remove-DnsServerResourceRecord -ZoneName $forwardZone -Name $vm `
|
||||
-RRType A -RecordData $existing.RecordData.IPv4Address `
|
||||
-ComputerName $dnsServer -Force
|
||||
}
|
||||
Add-DnsServerResourceRecordA -ZoneName $forwardZone -Name $vm `
|
||||
-IPv4Address $newIp -ComputerName $dnsServer -TimeToLive 00:05:00
|
||||
|
||||
# 2. 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
|
||||
}
|
||||
# 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)"
|
||||
|
||||
# 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)"
|
||||
}
|
||||
$summary += "ok $vm -> $newIp"
|
||||
}
|
||||
|
||||
# 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.
|
||||
|
||||
@@ -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"; \
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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; } = "";
|
||||
|
||||
@@ -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);
|
||||
File.Copy(Path, backupPath, overwrite: false);
|
||||
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 { }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 */ }
|
||||
}
|
||||
}
|
||||
@@ -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,105 +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;
|
||||
|
||||
var ok = MessageBox.Show(
|
||||
$"Roll the configuration back to the checkpoint from {Selected.SavedAt.ToLocalTime():yyyy-MM-dd HH:mm:ss}?\n\nThe current configuration is automatically saved as a new checkpoint first, so you can roll forward again.",
|
||||
"Confirm rollback",
|
||||
MessageBoxButton.OKCancel,
|
||||
MessageBoxImage.Warning);
|
||||
if (ok != MessageBoxResult.OK) return;
|
||||
|
||||
try
|
||||
{
|
||||
await _client.RestoreBackupAsync(Selected.FileName).ConfigureAwait(false);
|
||||
await RefreshAsync().ConfigureAwait(false);
|
||||
Application.Current.Dispatcher.Invoke(() =>
|
||||
StatusMessage = $"Rolled back to {Selected!.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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user