Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 821ff9b9ef | |||
| 4954e94d08 | |||
| 10341c63cb | |||
| 10b15fc17c | |||
| 1229c52ecf | |||
| 14d1bdc461 | |||
| 7c164ab3b3 | |||
| d89290aedb | |||
| ddd36a9116 | |||
| b66dd245c0 | |||
| 1ea724cd1f | |||
| a2bd338839 | |||
| b17d832842 | |||
| fe42f2f908 | |||
| 93a9c327e0 | |||
| 9e6abeef74 | |||
| 9525ee358e | |||
| f3bca1e8ff | |||
| a45d994c18 | |||
| 28479272d5 | |||
| 4ef8d20578 | |||
| 8b855ec9b9 | |||
| 1e48b8185b | |||
| 24d8701b65 | |||
| 27e5264714 | |||
| ab53c96928 | |||
| 87bcb6807f | |||
| 882d5332b4 | |||
| ebac2c7c04 | |||
| 8ecfe84540 | |||
| 2f61b342af |
@@ -0,0 +1,100 @@
|
||||
name: Release (Gitea)
|
||||
|
||||
# Lives in .gitea/workflows/ so it runs on Gitea Actions only. The GitHub-side
|
||||
# release lives in .github/workflows/release.yml.
|
||||
#
|
||||
# Triggered automatically on v* tag pushes; can also be invoked manually via
|
||||
# workflow_dispatch with a version override (useful for testing the runner
|
||||
# without bumping the project version).
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version to build (e.g. 0.1.4). Defaults to Directory.Build.props.'
|
||||
required: false
|
||||
|
||||
jobs:
|
||||
build-installer:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '8.0.x'
|
||||
|
||||
- name: Resolve version
|
||||
id: ver
|
||||
shell: pwsh
|
||||
run: |
|
||||
if ('${{ github.event_name }}' -eq 'push') {
|
||||
$v = '${{ github.ref_name }}'.TrimStart('v')
|
||||
} elseif ('${{ inputs.version }}') {
|
||||
$v = '${{ inputs.version }}'
|
||||
} else {
|
||||
[xml]$p = Get-Content Directory.Build.props
|
||||
$v = $p.Project.PropertyGroup.Version
|
||||
}
|
||||
"version=$v" | Out-File $env:GITHUB_OUTPUT -Append
|
||||
Write-Host "Building version $v"
|
||||
|
||||
- name: Restore + test
|
||||
shell: pwsh
|
||||
run: |
|
||||
dotnet restore WebhookServer.sln
|
||||
dotnet test WebhookServer.sln -c Release
|
||||
|
||||
- name: Ensure Inno Setup is installed
|
||||
shell: pwsh
|
||||
run: |
|
||||
if (-not (Get-Command iscc -ErrorAction SilentlyContinue) -and `
|
||||
-not (Test-Path 'C:\Program Files (x86)\Inno Setup 6\ISCC.exe') -and `
|
||||
-not (Test-Path 'C:\Program Files\Inno Setup 6\ISCC.exe')) {
|
||||
choco install innosetup --no-progress -y
|
||||
}
|
||||
|
||||
- name: Build installer
|
||||
shell: pwsh
|
||||
run: ./scripts/build-installer.ps1 -VersionOverride ${{ steps.ver.outputs.version }}
|
||||
|
||||
# actions/upload-artifact@v4 is GitHub-only ("GHESNotSupportedError" on
|
||||
# Gitea). The release-creation step below attaches the .exe via Gitea's
|
||||
# API directly, which is the only place we actually need to surface it.
|
||||
|
||||
- name: Create Gitea release with installer attached
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
shell: pwsh
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
$version = '${{ steps.ver.outputs.version }}'
|
||||
$tag = '${{ github.ref_name }}'
|
||||
$repo = '${{ github.repository }}'
|
||||
$serverUrl = '${{ github.server_url }}'
|
||||
$apiBase = "$serverUrl/api/v1/repos/$repo"
|
||||
$headers = @{ Authorization = "token $env:GITEA_TOKEN" }
|
||||
|
||||
# 1. Create the release.
|
||||
$isPre = $version.StartsWith('0.')
|
||||
$createBody = @{
|
||||
tag_name = $tag
|
||||
name = "Webhook Server $version"
|
||||
body = "Automated build via Gitea Actions runner."
|
||||
draft = $false
|
||||
prerelease = $isPre
|
||||
} | ConvertTo-Json
|
||||
$rel = Invoke-RestMethod -Uri "$apiBase/releases" -Method Post `
|
||||
-Headers $headers -ContentType 'application/json' -Body $createBody
|
||||
Write-Host "Created release id=$($rel.id) tag=$tag"
|
||||
|
||||
# 2. Attach the installer.
|
||||
$file = Get-Item "dist/WebhookServer-Setup-$version.exe"
|
||||
$uploadUri = "$apiBase/releases/$($rel.id)/assets?name=$($file.Name)"
|
||||
Invoke-RestMethod -Uri $uploadUri -Method Post -Headers $headers `
|
||||
-ContentType 'application/octet-stream' -InFile $file.FullName | Out-Null
|
||||
Write-Host "Uploaded $($file.Name) ($([math]::Round($file.Length / 1MB, 2)) MB) to $tag"
|
||||
@@ -0,0 +1,28 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '8.0.x'
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore WebhookServer.sln
|
||||
|
||||
- name: Build
|
||||
run: dotnet build WebhookServer.sln -c Release --no-restore
|
||||
|
||||
- name: Test
|
||||
run: dotnet test WebhookServer.sln -c Release --no-build --verbosity normal
|
||||
@@ -0,0 +1,74 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version to build (e.g. 0.1.0). Defaults to Directory.Build.props.'
|
||||
required: false
|
||||
|
||||
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
|
||||
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
|
||||
run: |
|
||||
dotnet restore WebhookServer.sln
|
||||
dotnet test WebhookServer.sln -c Release
|
||||
|
||||
- name: Install Inno Setup
|
||||
shell: pwsh
|
||||
run: |
|
||||
choco install innosetup --no-progress -y
|
||||
Write-Host "ISCC at: $((Get-Command iscc).Path)"
|
||||
|
||||
- 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 GitHub Release
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
name: Webhook Server ${{ steps.ver.outputs.version }}
|
||||
tag_name: ${{ github.ref_name }}
|
||||
draft: false
|
||||
prerelease: ${{ startsWith(steps.ver.outputs.version, '0.') }}
|
||||
files: dist/WebhookServer-Setup-*.exe
|
||||
generate_release_notes: true
|
||||
@@ -0,0 +1,31 @@
|
||||
name: Sync Wiki
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'docs/**'
|
||||
- 'scripts/sync-wiki.ps1'
|
||||
- '.github/workflows/wiki-sync.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
# Gitea reads .github/workflows/ for compatibility, but this workflow
|
||||
# pushes to a GitHub-hosted wiki. Skip on non-GitHub runners; the Gitea
|
||||
# wiki is synced separately via scripts/sync-wiki.ps1.
|
||||
if: github.server_url == 'https://github.com'
|
||||
runs-on: windows-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Sync docs/ to GitHub wiki
|
||||
shell: pwsh
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
$repo = '${{ github.repository }}'
|
||||
$wikiUrl = "https://x-access-token:$env:GH_TOKEN@github.com/$repo.wiki.git"
|
||||
./scripts/sync-wiki.ps1 -WikiUrl $wikiUrl
|
||||
@@ -0,0 +1,14 @@
|
||||
<Project>
|
||||
|
||||
<PropertyGroup>
|
||||
<Version>0.1.4</Version>
|
||||
<Authors>Justin Paul</Authors>
|
||||
<Company>Justin Paul</Company>
|
||||
<Product>Webhook Server</Product>
|
||||
<Copyright>Copyright (c) Justin Paul</Copyright>
|
||||
<PackageProjectUrl>https://jpaul.me</PackageProjectUrl>
|
||||
<RepositoryUrl>https://github.com/recklessop/webhook-server</RepositoryUrl>
|
||||
<RepositoryType>git</RepositoryType>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -229,6 +229,35 @@ Order in the request pipeline matters: **IP check runs before auth.** That avoid
|
||||
|
||||
This covers GitHub, Stripe, Slack, generic CI patterns by tweaking the four fields.
|
||||
|
||||
## Service account
|
||||
|
||||
The service itself runs fine under any account — this section is about which account makes sense for the **scripts** the service launches, since they inherit its identity.
|
||||
|
||||
| Account | Network identity | When to use |
|
||||
|---|---|---|
|
||||
| `LocalSystem` (default) | Computer account `DOMAIN\MACHINE$` on a domain-joined host; nothing on a workgroup host | Default. Local-only scripts, or read-only AD queries on a domain-joined machine. Most powerful local account — any webhook script effectively runs as SYSTEM. |
|
||||
| `LocalService` | None — no network credentials | **Don't.** Cannot talk to AD or any other remote resource that requires Windows auth. Listed only to rule it out. |
|
||||
| `NetworkService` | Computer account, same as LocalSystem | Slightly less local privilege than LocalSystem; same network identity. Rarely worth the switch. |
|
||||
| Domain user (`DOMAIN\svc-webhookserver`) | That user | Need write/admin operations against AD (password resets, group changes, OU creates). You own password rotation. |
|
||||
| **gMSA** (`DOMAIN\svc-webhookserver$`) | That gMSA | **Recommended for AD-write workloads.** AD generates and rotates the password automatically. Requires domain functional level 2012+ and `Install-ADServiceAccount` on the host. |
|
||||
|
||||
Install commands by account type:
|
||||
|
||||
```powershell
|
||||
# LocalSystem (default)
|
||||
sc.exe create WebhookServer binPath= "C:\path\WebhookServer.Service.exe" start= auto
|
||||
|
||||
# Domain user
|
||||
sc.exe create WebhookServer binPath= "..." obj= "DOMAIN\svc-webhookserver" password= "..." start= auto
|
||||
|
||||
# gMSA — note the trailing $ and no password=
|
||||
sc.exe create WebhookServer binPath= "..." obj= "DOMAIN\svc-webhookserver$" start= auto
|
||||
```
|
||||
|
||||
`scripts/install-service.ps1` will accept a `-ServiceAccount` parameter that defaults to `LocalSystem` and accepts a domain user or gMSA name. README will document the gMSA setup once for users who need AD writes from their hooks.
|
||||
|
||||
The service code itself makes no assumptions about the account — DPAPI uses `LocalMachine` scope so secret decryption works under any local identity.
|
||||
|
||||
## Secret storage (DPAPI)
|
||||
|
||||
Endpoint `Secret` is stored in JSON as `{ "encrypted": "<base64 of ProtectedData.Protect(utf8(secret), null, LocalMachine)>" }`. Decrypt only inside the service when needed. The GUI submits secrets in plaintext over the named pipe (local-machine, ACL-restricted), service encrypts before writing.
|
||||
|
||||
@@ -1,88 +1,92 @@
|
||||
# webhook-server
|
||||
# Webhook Server
|
||||
|
||||
A Windows-native webhook server that runs PowerShell, PowerShell Core, cmd / `.bat`, or arbitrary executables in response to incoming HTTP requests. Endpoints are configured in a desktop GUI; the actual server runs as a Windows Service so it survives reboots and works without anyone logged in.
|
||||
A Windows-native webhook server that runs PowerShell, cmd / `.bat`, or any executable in response to incoming HTTP requests. Endpoints are configured in a desktop GUI; the actual server runs as a Windows Service so it survives reboots and works without anyone logged in.
|
||||
|
||||
**Status:** planning complete, implementation pending. See [PLAN.md](PLAN.md) for the full design.
|
||||
Designed for sysadmins who want to wire up tools like **Zerto pre/post scripts**, GitHub webhooks, monitoring alerts, or backup jobs to Windows-side automation — without writing a custom listener every time.
|
||||
|
||||
## Quickstart
|
||||
|
||||
1. **Download** the latest installer: <https://github.com/recklessop/webhook-server/releases/latest>
|
||||
2. **Run it.** UAC accept → next, next, finish. Adds a Start Menu entry, registers and starts the Windows Service.
|
||||
3. **Open Webhook Server** from the Start Menu (auto-elevates).
|
||||
4. **File → New endpoint**, configure a slug + script, save, hit the URL.
|
||||
|
||||
Full first-time walkthrough: [docs/installation.md](docs/installation.md)
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Many endpoints, one service.** Each webhook is a configured URL slug mapped to a script or command.
|
||||
- **Per-endpoint auth.** Pick HMAC signature (GitHub/Stripe-style), bearer token, or none.
|
||||
- **Per-endpoint IP allowlist.** Restrict by IP or CIDR (IPv4 + IPv6). Empty list = open. Checked before auth.
|
||||
- **Per-endpoint auth** — HMAC signature (GitHub / Stripe / Slack style), bearer token, or none.
|
||||
- **Per-endpoint IP allowlist.** Restrict by IP or CIDR. Empty list = open. Checked before auth so blocked IPs get a fast 403.
|
||||
- **Per-endpoint Run As** — run the hook as the service account (default), the user logged in at the keyboard (for UI hooks), or a named domain/local user via password.
|
||||
- **Flexible execution.** Windows PowerShell 5.1, PowerShell 7+, cmd / `.bat`, or any `.exe`.
|
||||
- **Flexible input.** Any combination of: JSON body to stdin, query/headers as env vars, `{{template}}` arg expansion.
|
||||
- **Sync or async per endpoint.** Sync returns exit code + stdout/stderr; async returns 202 immediately.
|
||||
- **Outbound callbacks.** Optional per-endpoint callback URL — service POSTs the run result back after the script finishes. Required for async callers who want to know what happened. HMAC-signed, retried with backoff. Pre-configured only (no SSRF surface from caller-supplied URLs).
|
||||
- **Service-first.** Always-on Windows Service. The WPF GUI is a thin config/monitor client over a named pipe.
|
||||
- **HTTPS optional.** Bind a `.pfx` or cert-store thumbprint from the GUI; HTTP works out of the box.
|
||||
- **Secrets at rest.** Tokens and HMAC secrets are encrypted via DPAPI (LocalMachine scope) in `config.json`.
|
||||
- **Flexible input** — any combination of: JSON body to stdin, query / headers as env vars, `{{body.foo.bar}}` template expansion into argv.
|
||||
- **Sync or async per endpoint.** Sync returns exit code + stdout / stderr to the caller; async returns 202 immediately.
|
||||
- **Outbound callbacks.** Optional per-endpoint URL the service POSTs run results to after the script finishes. HMAC-signed, retry-with-backoff. Required for async callers who want to know what happened.
|
||||
- **Configurable network** — bind to specific NICs, set the URL host shown in the GUI, configure trusted reverse proxies.
|
||||
- **HTTPS optional.** Bind a `.pfx` or cert-store thumbprint from the GUI.
|
||||
- **Secrets at rest** — bearer tokens, HMAC keys, RunAs passwords, and PFX passwords are DPAPI-encrypted (LocalMachine scope) in `config.json`.
|
||||
- **Auto-snapshots.** Every config save writes a Config Checkpoint; restore to any point with one click. Last 30 retained.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
+------------------+ named pipe +------------------------------+
|
||||
| WPF GUI app | <----------> | Windows Service |
|
||||
| (config/monitor)| | - Kestrel: webhook listener |
|
||||
+------------------+ | - Named-pipe admin server |
|
||||
| - Executor pool |
|
||||
+------------------+ named pipe +-------------------------------+
|
||||
| GUI (WPF) | <-------------> | Windows Service |
|
||||
| add / edit / | SYSTEM+admin | - Kestrel: hook listener |
|
||||
| view logs | ACL'd | - Admin pipe server |
|
||||
+------------------+ | - Executor (process runner) |
|
||||
| - Callback dispatcher |
|
||||
| - Serilog file logging |
|
||||
+------------------------------+
|
||||
^
|
||||
+-------------------------------+
|
||||
|
|
||||
C:\ProgramData\WebhookServer\
|
||||
- config.json (DPAPI-encrypted secrets)
|
||||
- logs\*.log
|
||||
- config.json (DPAPI-encrypted)
|
||||
- backups\ (auto-snapshots)
|
||||
- logs\ (daily rolling)
|
||||
```
|
||||
|
||||
## Project layout (planned)
|
||||
## Documentation
|
||||
|
||||
```
|
||||
WebhookServer.sln
|
||||
src/
|
||||
WebhookServer.Core/ class lib: models, auth, execution, storage, IPC
|
||||
WebhookServer.Service/ .NET 8 Worker Service (hosts Kestrel + admin pipe)
|
||||
WebhookServer.Gui/ WPF (.NET 8) MVVM config/monitor client
|
||||
scripts/
|
||||
install-service.ps1
|
||||
uninstall-service.ps1
|
||||
```
|
||||
Everything you need to operate the server:
|
||||
|
||||
- [Concepts](docs/concepts.md) — what a webhook is and how this server uses one
|
||||
- [Installation](docs/installation.md) — interactive and silent install
|
||||
- [Upgrading](docs/upgrading.md) — single click; what's preserved
|
||||
- [Uninstalling](docs/uninstalling.md) — clean removal
|
||||
- [Run As modes](docs/runas-modes.md) — Service / InteractiveUser / SpecificUser
|
||||
- [Service account & Active Directory](docs/service-account-and-ad.md) — gMSA + delegated rights
|
||||
- [Network & security](docs/network-and-security.md) — bind addresses, allowlists, HTTPS, secrets
|
||||
- [Troubleshooting](docs/troubleshooting.md) — common errors and where to look
|
||||
|
||||
Recipes:
|
||||
|
||||
- [Zerto failover post-script → DNS + service checks](docs/recipes/zerto-pre-post-scripts.md) ← **canonical use case** (Windows ZVM)
|
||||
- [Zerto ZVMA (Kubernetes) pre/post → notify + VM health check](docs/recipes/zerto-zvma-pre-post.md) — same pattern for the in-cluster scripts-service
|
||||
- [GitHub-style HMAC-signed webhook](docs/recipes/github-style-hmac.md)
|
||||
- [Pop UI on the user's desktop](docs/recipes/ui-on-desktop.md)
|
||||
|
||||
Ready-to-drop-in Zerto-side scripts are included at [`scripts/examples/zerto-post-failover.ps1`](scripts/examples/zerto-post-failover.ps1) (Windows ZVM) and [`scripts/examples/zerto-zvma-send.ps1`](scripts/examples/zerto-zvma-send.ps1) (ZVMA / Kubernetes); receiver examples for the ZVMA recipe ship as [`zerto-receiver-notify.ps1`](scripts/examples/zerto-receiver-notify.ps1) and [`zerto-receiver-vm-healthcheck.ps1`](scripts/examples/zerto-receiver-vm-healthcheck.ps1).
|
||||
|
||||
## Requirements
|
||||
|
||||
- Windows 10 / 11 or Windows Server 2019+
|
||||
- .NET 8 SDK to build, .NET 8 Runtime (or self-contained publish) to run
|
||||
- Administrator rights to install the service and to run the GUI (the admin named pipe is ACL'd to SYSTEM + Administrators)
|
||||
- Windows 10 / 11 / Server 2019+
|
||||
- x64
|
||||
- .NET 8 SDK to build (the released installer includes everything else)
|
||||
|
||||
## Building (on Windows)
|
||||
## Building from source
|
||||
|
||||
```powershell
|
||||
dotnet restore
|
||||
dotnet build -c Release
|
||||
dotnet publish src/WebhookServer.Service -c Release -r win-x64 --self-contained
|
||||
dotnet publish src/WebhookServer.Gui -c Release -r win-x64 --self-contained
|
||||
git clone https://github.com/recklessop/webhook-server.git
|
||||
cd webhook-server
|
||||
|
||||
# Dev install (publishes + copies to C:\Program Files\WebhookServer + registers service)
|
||||
powershell -ExecutionPolicy Bypass -File scripts\deploy.ps1
|
||||
|
||||
# Or build the installer locally (requires Inno Setup 6: winget install JRSoftware.InnoSetup)
|
||||
powershell -ExecutionPolicy Bypass -File scripts\build-installer.ps1
|
||||
```
|
||||
|
||||
## Installing the service (on Windows)
|
||||
|
||||
```powershell
|
||||
# from an elevated PowerShell prompt
|
||||
sc.exe create WebhookServer binPath= "C:\Program Files\WebhookServer\WebhookServer.Service.exe" start= auto
|
||||
sc.exe start WebhookServer
|
||||
```
|
||||
|
||||
`scripts/install-service.ps1` will wrap this once implemented.
|
||||
|
||||
## Configuration
|
||||
|
||||
The service reads `C:\ProgramData\WebhookServer\config.json`. Edit it through the GUI rather than by hand — the GUI handles DPAPI encryption of secrets and validation of IP allowlist entries.
|
||||
|
||||
## Out of scope for v1
|
||||
|
||||
- Importing/exporting config across machines (DPAPI LocalMachine scope ties decryption to the host).
|
||||
- Outbound webhook delivery / retry queues.
|
||||
- Per-endpoint rate limiting.
|
||||
- Multi-user RBAC for the GUI.
|
||||
- Auto-update.
|
||||
|
||||
## License
|
||||
|
||||
Not yet chosen.
|
||||
TBD.
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{262B1849-BA2B-45DB-9DB1-5D4D9E1E1129}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebhookServer.Core", "src\WebhookServer.Core\WebhookServer.Core.csproj", "{0D2E9E23-BA5E-4C8C-A620-C263A21A78C4}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebhookServer.Service", "src\WebhookServer.Service\WebhookServer.Service.csproj", "{83E8FF0E-64EB-4D34-99DA-92BD1E12B670}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebhookServer.Gui", "src\WebhookServer.Gui\WebhookServer.Gui.csproj", "{ABF4583D-F821-4EAC-A053-56309FF7549E}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{A80D481D-557B-4C98-8C28-C8F4185B6537}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebhookServer.Core.Tests", "tests\WebhookServer.Core.Tests\WebhookServer.Core.Tests.csproj", "{27C42691-FF90-4885-A8E3-5EBB91D847DF}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{0D2E9E23-BA5E-4C8C-A620-C263A21A78C4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{0D2E9E23-BA5E-4C8C-A620-C263A21A78C4}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{0D2E9E23-BA5E-4C8C-A620-C263A21A78C4}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{0D2E9E23-BA5E-4C8C-A620-C263A21A78C4}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{83E8FF0E-64EB-4D34-99DA-92BD1E12B670}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{83E8FF0E-64EB-4D34-99DA-92BD1E12B670}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{83E8FF0E-64EB-4D34-99DA-92BD1E12B670}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{83E8FF0E-64EB-4D34-99DA-92BD1E12B670}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{ABF4583D-F821-4EAC-A053-56309FF7549E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{ABF4583D-F821-4EAC-A053-56309FF7549E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{ABF4583D-F821-4EAC-A053-56309FF7549E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{ABF4583D-F821-4EAC-A053-56309FF7549E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{27C42691-FF90-4885-A8E3-5EBB91D847DF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{27C42691-FF90-4885-A8E3-5EBB91D847DF}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{27C42691-FF90-4885-A8E3-5EBB91D847DF}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{27C42691-FF90-4885-A8E3-5EBB91D847DF}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{0D2E9E23-BA5E-4C8C-A620-C263A21A78C4} = {262B1849-BA2B-45DB-9DB1-5D4D9E1E1129}
|
||||
{83E8FF0E-64EB-4D34-99DA-92BD1E12B670} = {262B1849-BA2B-45DB-9DB1-5D4D9E1E1129}
|
||||
{ABF4583D-F821-4EAC-A053-56309FF7549E} = {262B1849-BA2B-45DB-9DB1-5D4D9E1E1129}
|
||||
{27C42691-FF90-4885-A8E3-5EBB91D847DF} = {A80D481D-557B-4C98-8C28-C8F4185B6537}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
@@ -0,0 +1,33 @@
|
||||
# Webhook Server documentation
|
||||
|
||||
Webhook Server is a Windows service that runs a script (PowerShell, cmd, or any executable) when an HTTP request hits a URL you choose. It's designed for sysadmins who want to wire a tool like **Zerto pre/post scripts**, GitHub Actions, a monitoring system, or a backup tool into a Windows-side automation step — without writing a custom listener every time.
|
||||
|
||||
## New here? Start with these
|
||||
|
||||
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
|
||||
|
||||
## Topical
|
||||
|
||||
- [Upgrading](upgrading.md)
|
||||
- [Uninstalling](uninstalling.md)
|
||||
- [Run As modes — when to use which](runas-modes.md)
|
||||
- [Service account & Active Directory](service-account-and-ad.md)
|
||||
- [Network & security](network-and-security.md)
|
||||
- [Troubleshooting](troubleshooting.md)
|
||||
|
||||
## Recipes (cookbook style)
|
||||
|
||||
- [Zerto failover post-script → DNS + service checks](recipes/zerto-pre-post-scripts.md) ← canonical use case (Windows ZVM)
|
||||
- [Zerto ZVMA (Kubernetes) pre/post → notify + VM health check](recipes/zerto-zvma-pre-post.md) — same pattern for the in-cluster scripts-service
|
||||
- [GitHub-style HMAC-signed webhook](recipes/github-style-hmac.md)
|
||||
- [Pop UI on the user's desktop](recipes/ui-on-desktop.md)
|
||||
|
||||
The flagship Zerto recipe ships with a ready-to-use Zerto-side post-script at [`scripts/examples/zerto-post-failover.ps1`](../scripts/examples/zerto-post-failover.ps1). The ZVMA recipe ships with [`zerto-zvma-send.ps1`](../scripts/examples/zerto-zvma-send.ps1) (sender) plus [`zerto-receiver-notify.ps1`](../scripts/examples/zerto-receiver-notify.ps1) and [`zerto-receiver-vm-healthcheck.ps1`](../scripts/examples/zerto-receiver-vm-healthcheck.ps1) (receivers).
|
||||
|
||||
## Reference
|
||||
|
||||
- [GitHub repo](https://github.com/recklessop/webhook-server)
|
||||
- [Latest release](https://github.com/recklessop/webhook-server/releases/latest)
|
||||
- [Issue tracker](https://github.com/recklessop/webhook-server/issues)
|
||||
@@ -0,0 +1,77 @@
|
||||
# Concepts
|
||||
|
||||
If you've never used a webhook before, this is where to start. Five minutes, no surprises.
|
||||
|
||||
## What is a webhook?
|
||||
|
||||
A webhook is just **an HTTP URL that runs something when it gets called.** Some other tool — Zerto, GitHub, your monitoring system, a backup job — does an `HTTP POST` to that URL when an event happens. Whatever's listening on the URL takes that request and does work in response.
|
||||
|
||||
Concretely, a Zerto pre-script might do:
|
||||
|
||||
```powershell
|
||||
Invoke-WebRequest -Method POST -Uri http://webhooks.contoso.local:8080/hook/start-failover `
|
||||
-Body (@{ vmName = $env:ZertoVPGName } | ConvertTo-Json) `
|
||||
-ContentType application/json
|
||||
```
|
||||
|
||||
…and the server at `webhooks.contoso.local:8080` would receive that POST and run a PowerShell script you wrote.
|
||||
|
||||
## What does this server give you that you don't already have?
|
||||
|
||||
You could write a tiny ASP.NET listener, or run a PowerShell script behind IIS, or hand-craft `HttpListener` plumbing. People do, all the time. The trade-off is that **you then own the listener** — auth, retries, logging, restarts, a service wrapper, secret storage, an admin UI. That's where Webhook Server saves you a weekend.
|
||||
|
||||
What you get out of the box:
|
||||
|
||||
- A real **Windows Service** that survives reboots and runs without anyone logged in
|
||||
- Per-endpoint **authentication**: Bearer token, HMAC-signed (GitHub / Stripe / Slack style), or none
|
||||
- Per-endpoint **IP allowlist** (single IPs or CIDR ranges)
|
||||
- **Run-as identity**: the service runs as `LocalSystem` by default, but each individual hook can run as a domain account, the logged-in user, or whoever — without needing Task Scheduler in the middle
|
||||
- **Logging** (Serilog, daily-rolling files) plus a GUI tail
|
||||
- A WPF **GUI** for adding / editing / testing endpoints. No JSON file editing required.
|
||||
- **Outbound callbacks**: when a hook finishes, the server can POST the result to another URL, signed with HMAC, with retry-and-backoff
|
||||
- **HTTPS** via `.pfx` or a cert thumbprint from the local cert store
|
||||
- **Auto-snapshots** of your config on every save, with point-in-time restore from the GUI
|
||||
|
||||
## How the moving parts fit together
|
||||
|
||||
```
|
||||
+------------------+ named pipe +-------------------------------+
|
||||
| GUI (WPF) | <------------> | Windows Service |
|
||||
| add / edit / | SYSTEM+admin | - Kestrel: hook listener |
|
||||
| view logs | ACL'd | - Admin pipe server |
|
||||
+------------------+ | - Executor (process runner) |
|
||||
| - Callback dispatcher |
|
||||
| - Serilog file logging |
|
||||
+-------------------------------+
|
||||
|
|
||||
C:\ProgramData\WebhookServer\
|
||||
- config.json (DPAPI-encrypted secrets)
|
||||
- backups\ (auto-snapshots)
|
||||
- logs\ (daily rolling)
|
||||
```
|
||||
|
||||
- The **Windows Service** does the actual work: listens for HTTP requests, runs your scripts, writes logs.
|
||||
- The **GUI** is purely a config + monitoring tool. It talks to the service over a named pipe ACL'd to `SYSTEM` and `Administrators`. You can launch and close the GUI as you like; the service keeps running.
|
||||
- **Config + secrets** live in `C:\ProgramData\WebhookServer\config.json`. Secrets (bearer tokens, HMAC keys, run-as passwords, PFX passwords) are DPAPI-encrypted with the `LocalMachine` scope, so the same machine can decrypt them under any account but they don't travel to other machines.
|
||||
|
||||
## What's an "endpoint"?
|
||||
|
||||
An endpoint is one URL slug (the part after `/hook/`) plus a configuration: who's allowed to call it, how it's authenticated, what to run when it fires, and what to do with the result. Add as many as you want.
|
||||
|
||||
| Field | What it controls |
|
||||
|---|---|
|
||||
| **Slug** | The URL path. `deploy` → `http://host:8080/hook/deploy` |
|
||||
| **Auth** | None / Bearer / HMAC. None means anyone who can reach the URL can fire it. |
|
||||
| **Allowed clients** | List of IPs or CIDRs allowed to hit this slug. Empty = anyone reachable. |
|
||||
| **Executor** | What to run: Windows PowerShell 5.1, PowerShell Core (7+), `cmd` / `.bat`, or a path to any `.exe` |
|
||||
| **Run As** | Who the script runs as. See [Run As modes](runas-modes.md). |
|
||||
| **Data passing** | How request data reaches the script — JSON to stdin, headers / query as env vars, `{{template}}` arg expansion |
|
||||
| **Response mode** | Sync (the HTTP caller waits for the script to finish and gets its output) or Async (returns 202 immediately, runs in background) |
|
||||
| **Callback** | Optional outbound URL the server POSTs to with the run result. Required for async hooks if the original caller wants the result. |
|
||||
|
||||
## What it isn't
|
||||
|
||||
- **Not an HTTP server for serving static files or pages.** Just hook URLs and a `/healthz`.
|
||||
- **Not a queue.** No durable persistence of inbound requests; if the service crashes mid-execution that run is lost (the inbound caller will see the connection drop or a timeout).
|
||||
- **Not multi-tenant.** It's one config, one set of endpoints, one machine. Run multiple instances on different ports / different machines if you need separation.
|
||||
- **Not an internet-facing public-API server out of the box.** Lock down with HTTPS + auth + IP allowlist + a reverse proxy if you're going to expose it publicly. See [network & security](network-and-security.md).
|
||||
@@ -0,0 +1,108 @@
|
||||
# Installation
|
||||
|
||||
This page covers a fresh install. If you already have Webhook Server installed, see [Upgrading](upgrading.md). To remove it, see [Uninstalling](uninstalling.md).
|
||||
|
||||
## Requirements
|
||||
|
||||
- Windows 10, Windows 11, or Windows Server 2019 / 2022 / 2025
|
||||
- Administrator rights to install the service and to run the GUI
|
||||
- (Optional, only if you publish from source) .NET 8 SDK
|
||||
|
||||
The installer is **x64 only**. There is no x86 build.
|
||||
|
||||
## 1. Download
|
||||
|
||||
Grab the latest installer from the GitHub Releases page:
|
||||
|
||||
> https://github.com/recklessop/webhook-server/releases/latest
|
||||
|
||||
Look for the asset named `WebhookServer-Setup-X.Y.Z.exe`.
|
||||
|
||||
## 2. Run the installer
|
||||
|
||||
Double-click the `.exe`. UAC will prompt — accept. The wizard:
|
||||
|
||||
- Copies the binaries to `C:\Program Files\WebhookServer\`
|
||||
- Creates a Start Menu folder named **Webhook Server** with a GUI shortcut + Uninstall shortcut
|
||||
- Optionally creates a desktop shortcut (checkbox; off by default)
|
||||
- **Registers the Windows Service** named `WebhookServer`, runs it as `LocalSystem`, sets it to start automatically at boot, and configures it to restart on failure
|
||||
- Starts the service
|
||||
- Offers to launch the GUI when finished — leave the checkbox ticked
|
||||
|
||||
The first time the GUI opens, you'll see UAC prompt again because the GUI requires elevation (it talks to the service over a named pipe restricted to `SYSTEM` and the `Administrators` group). Accept it.
|
||||
|
||||
If the GUI's status bar shows a green dot and `Connected — HTTP 8080`, you're done.
|
||||
|
||||
> **Default ports**: HTTP on `8080`, HTTPS off. Both can be changed under **Server → Settings**. Port `8080` is rarely in use on a fresh server but conflicts with some other tools — if you see `Connection refused` later, this is the first thing to check.
|
||||
|
||||
## 3. Add your first endpoint
|
||||
|
||||
In the GUI:
|
||||
|
||||
1. **File → New endpoint**
|
||||
2. Slug: `ping`
|
||||
3. Auth → Mode: **None**
|
||||
4. Executor → Type: **Windows PowerShell**
|
||||
5. Executor → Inline command: `Write-Output 'pong'`
|
||||
6. Click **Save**
|
||||
|
||||
The endpoint appears in the grid. Right-click it → **Copy URL**, paste into a browser. You should get back something like:
|
||||
|
||||
```json
|
||||
{ "runId": "...", "exitCode": 0, "durationMs": 134, "stdout": "pong\r\n", ... }
|
||||
```
|
||||
|
||||
That's it. Real-world recipes start with [Zerto pre/post scripts → AD / DNS update](recipes/zerto-pre-post-scripts.md).
|
||||
|
||||
## Silent / unattended install
|
||||
|
||||
For deploying to many machines via Group Policy, SCCM, Intune, Ansible, etc. — the installer is built with [Inno Setup](https://jrsoftware.org/isinfo.php) and supports its standard silent-mode flags:
|
||||
|
||||
```powershell
|
||||
WebhookServer-Setup-0.1.1.exe /VERYSILENT /SUPPRESSMSGBOXES /NORESTART
|
||||
```
|
||||
|
||||
Useful flags:
|
||||
|
||||
| Flag | What it does |
|
||||
|---|---|
|
||||
| `/SILENT` | Show progress, no questions |
|
||||
| `/VERYSILENT` | No UI at all |
|
||||
| `/SUPPRESSMSGBOXES` | Suppress info / error popups (use with `/SILENT` or `/VERYSILENT`) |
|
||||
| `/NORESTART` | Don't restart automatically — there's nothing here that needs it, but pair with `/SUPPRESSMSGBOXES` for total quiet |
|
||||
| `/DIR="C:\Tools\WebhookServer"` | Override the install location |
|
||||
| `/LOG="C:\Temp\install.log"` | Write a verbose installer log |
|
||||
| `/TASKS="desktopicon"` | Pre-tick the optional desktop-icon task |
|
||||
|
||||
The post-install service install runs the same `install-service.ps1` script regardless of silent flags.
|
||||
|
||||
## Manual install from source (if you don't want to trust the prebuilt installer)
|
||||
|
||||
```powershell
|
||||
# clone (or your fork)
|
||||
git clone https://github.com/recklessop/webhook-server.git
|
||||
cd webhook-server
|
||||
|
||||
# from an elevated PowerShell:
|
||||
powershell -ExecutionPolicy Bypass -File scripts\deploy.ps1
|
||||
```
|
||||
|
||||
`deploy.ps1` publishes both projects, copies the binaries to `C:\Program Files\WebhookServer\`, registers the service, and starts it. Re-run after a `git pull` to upgrade.
|
||||
|
||||
To run the service under a non-default account (e.g. a gMSA for AD operations), pass `-ServiceAccount`:
|
||||
|
||||
```powershell
|
||||
.\scripts\deploy.ps1 -ServiceAccount 'CONTOSO\svc-webhookserver$'
|
||||
```
|
||||
|
||||
See [Service account & Active Directory](service-account-and-ad.md) for the full picture.
|
||||
|
||||
## Where things live after install
|
||||
|
||||
| Path | What |
|
||||
|---|---|
|
||||
| `C:\Program Files\WebhookServer\` | Binaries (`WebhookServer.Service.exe`, `WebhookServer.Gui.exe`, the icon, install/uninstall scripts) |
|
||||
| `C:\ProgramData\WebhookServer\config.json` | The configuration. Backups in `backups\`, daily-rolling logs in `logs\`. **Don't edit by hand** — secrets are DPAPI-encrypted and the service won't pick up your changes without a reload. Use the GUI. |
|
||||
| `\\.\pipe\WebhookServerAdmin` | The named pipe the GUI uses to talk to the service. ACL'd to `SYSTEM` + `Administrators` only. |
|
||||
|
||||
The installer never touches `C:\ProgramData\WebhookServer\`. Uninstalling preserves your config and logs by default; see [Uninstalling](uninstalling.md) for how to wipe them too.
|
||||
@@ -0,0 +1,131 @@
|
||||
# Network & security
|
||||
|
||||
This page covers what's exposed by Webhook Server, how to lock it down, and what's safe to change vs. leave alone.
|
||||
|
||||
## What's listening
|
||||
|
||||
By default the service binds Kestrel to **all interfaces on TCP 8080**. There are two endpoints relevant to outsiders:
|
||||
|
||||
- `GET|POST /hook/<slug>` — fires a configured endpoint
|
||||
- `GET /healthz` — returns `{"ok": true}` for monitoring
|
||||
- `GET /favicon.ico` — returns 204 to keep browser logs clean
|
||||
|
||||
Plus the admin named pipe `\\.\pipe\WebhookServerAdmin`, which is **only available locally** to processes running as SYSTEM or in the Administrators group.
|
||||
|
||||
## Reducing the network exposure
|
||||
|
||||
### Bind only to specific NICs
|
||||
|
||||
By default the server listens on every IP the host has — useful on a single-NIC desktop, dangerous on a multi-NIC server where one NIC faces the internet.
|
||||
|
||||
In the GUI: **Server → Settings → Network**. Untick "Listen on all interfaces" and tick the specific addresses you want. Save. The service restarts automatically and rebinds.
|
||||
|
||||
Common patterns:
|
||||
|
||||
- **Internal-only**: tick the LAN IP(s), leave loopback ticked too if anything on the box itself calls the hook
|
||||
- **Loopback-only**: tick `127.0.0.1` and `::1`. Useful when a reverse proxy on the same host fronts the public traffic.
|
||||
- **One specific IP for hooks**: tick a single IP that you've documented as the webhook endpoint. Helps when you have a multi-homed server and want clear network segmentation.
|
||||
|
||||
### Per-endpoint IP allowlist
|
||||
|
||||
Each endpoint has an **IP allowlist** field. Empty means anyone reachable can call it. Non-empty means deny-by-default — only the listed IPs / CIDRs are allowed:
|
||||
|
||||
```
|
||||
192.168.1.0/24
|
||||
10.42.0.5
|
||||
fd00::/8
|
||||
```
|
||||
|
||||
Mixing IPv4 and IPv6 entries is fine. The check runs **before authentication**, so a blocked IP gets a fast 403 without burning CPU on HMAC validation.
|
||||
|
||||
### Trusted proxies (X-Forwarded-For)
|
||||
|
||||
If the server sits behind a reverse proxy (nginx / IIS / Caddy / Cloudflare Tunnel), the inbound `RemoteIpAddress` will always be the proxy. To make the IP allowlist evaluate the original client instead, configure **Server → Settings → Trusted proxies** with the IP(s) of the proxy:
|
||||
|
||||
```
|
||||
10.0.0.5
|
||||
```
|
||||
|
||||
When the inbound connection comes from that IP and includes an `X-Forwarded-For` header, the leftmost entry of the header is treated as the effective client IP for the allowlist check.
|
||||
|
||||
If `Trusted proxies` is empty (default), `X-Forwarded-For` is **ignored entirely**. This is the safe default — it prevents anyone from spoofing their IP by adding the header themselves.
|
||||
|
||||
## Authentication options
|
||||
|
||||
| Mode | When to use | What the caller sends |
|
||||
|---|---|---|
|
||||
| **None** | Internal-only on a trusted LAN, or a hook that's safe to fire repeatedly with no side effects | Nothing |
|
||||
| **Bearer** | Simple authentication. Pick a long random secret and treat it as a password. | `Authorization: Bearer <secret>` |
|
||||
| **HMAC** | Anything where the body matters and you want tamper-evidence: GitHub webhooks, Stripe events, signed callbacks | A header (default `X-Hub-Signature-256`) containing `sha256=<hex digest>` of the request body keyed by your shared secret |
|
||||
|
||||
For **None**, lean hard on the IP allowlist — that's your only defense.
|
||||
|
||||
For **Bearer**, generate the secret with `[Convert]::ToBase64String((1..32 | %{ Get-Random -Maximum 256 }))` or any password manager. 32+ bytes of entropy. The token sits in `Authorization` headers; HTTPS is **strongly recommended** so it doesn't traverse the network in clear text.
|
||||
|
||||
For **HMAC**, the secret never traverses the network — only the digest does. This is what GitHub / Stripe / Slack use, and it's the right pick for inbound webhooks from internet-facing services. Configure the four fields to match the sender:
|
||||
|
||||
- **Algorithm**: usually SHA256
|
||||
- **Header name**: e.g. `X-Hub-Signature-256` (GitHub), `X-Slack-Signature` (Slack), `Stripe-Signature` (Stripe — needs different format)
|
||||
- **Prefix**: `sha256=` for GitHub-style, none for raw hex
|
||||
- **Encoding**: hex (most senders) or base64 (some Slack-derived implementations)
|
||||
|
||||
## HTTPS
|
||||
|
||||
HTTP-only is fine for fully-internal use. For anything reachable beyond a trusted LAN, enable HTTPS.
|
||||
|
||||
In **Server → Settings → HTTPS**:
|
||||
|
||||
- **PFX file**: path to a `.pfx` and its password. Easiest if you got a cert from your internal CA or generated a self-signed one with `New-SelfSignedCertificate`.
|
||||
- **Cert store thumbprint**: the SHA-1 thumbprint of a certificate already imported into `LocalMachine\My`. Best for production where IT manages the cert lifecycle (auto-renewal, revocation).
|
||||
|
||||
The **HTTPS port** defaults to 8443. Both HTTP and HTTPS can be active simultaneously — change `HTTP port` and `HTTPS port` independently.
|
||||
|
||||
After saving HTTPS settings the service restarts and rebinds. There is briefly a "Disconnected" state in the GUI while that happens (1–3 seconds).
|
||||
|
||||
### Using Let's Encrypt
|
||||
|
||||
The server doesn't speak ACME directly. Two practical options:
|
||||
|
||||
1. **Reverse proxy approach** — run nginx / Caddy / IIS in front of Webhook Server. The proxy handles Let's Encrypt; Webhook Server stays HTTP-only on loopback. Configure `Trusted proxies` so allowlists still work on the original client IP.
|
||||
2. **External cert renewal** — use [`win-acme`](https://www.win-acme.com/) to obtain certs and place them in `LocalMachine\My`. Configure HTTPS by **thumbprint** in the GUI. When `win-acme` rotates the cert it produces a new thumbprint, so you'll need to update the GUI; or have a small scheduled task that calls the admin pipe to update the binding (advanced, undocumented for now).
|
||||
|
||||
## Secrets at rest
|
||||
|
||||
All secrets — bearer tokens, HMAC keys, PFX passwords, RunAs passwords — are encrypted in `config.json` using **DPAPI with the `LocalMachine` scope**:
|
||||
|
||||
- The same machine can decrypt them under any account (so changing the service account doesn't break secret access).
|
||||
- Copying `config.json` to a different machine **doesn't carry the secrets** — DPAPI LocalMachine binds to the host's machine key. This is by design and protects against config exfiltration.
|
||||
- The GUI displays decrypted secrets in plaintext for an admin user. This is intentional. Anyone who can connect to the admin pipe is already SYSTEM-equivalent on the host; pretending otherwise just makes secret recovery harder.
|
||||
|
||||
For backup-and-restore across machines, you'd need to either:
|
||||
|
||||
- Re-enter all secrets on the new host (use the **Export config → manual secret re-entry** flow)
|
||||
- Bind a custom DPAPI scope (not currently supported — would require a v0.x feature request)
|
||||
|
||||
## The admin pipe
|
||||
|
||||
`\\.\pipe\WebhookServerAdmin` carries the GUI's commands to the service. Its security descriptor allows full control to:
|
||||
|
||||
- `NT AUTHORITY\SYSTEM`
|
||||
- `BUILTIN\Administrators`
|
||||
|
||||
Everyone else gets denied at the OS level — there's no auth layer in the protocol itself because the ACL is the auth layer. UAC token splitting means a non-elevated process owned by an Admin user is **also denied** (because the user's standard token has Admins as deny-only). That's why the GUI exe is manifested with `requireAdministrator` — it auto-elevates so the pipe accepts the connection.
|
||||
|
||||
If you ever need to grant pipe access to another local group (e.g., a custom `WebhookOperators` group), edit `src/WebhookServer.Core/Ipc/PipeSecurityFactory.cs` and add an `AddAccessRule` for that group. Currently no GUI configures this.
|
||||
|
||||
## Threat model summary
|
||||
|
||||
What you're protected against, by default:
|
||||
|
||||
- **Random scanners hitting your hooks** — solved by IP allowlists (when configured), auth (when configured), and HTTPS (when configured)
|
||||
- **Replay of inbound requests** — HMAC signs the body, so a captured request can't be modified, but it CAN be replayed. If that matters, include a timestamp in the body and reject old timestamps in your script.
|
||||
- **Credential leaks** — secrets at rest are DPAPI-encrypted, machine-bound; they don't travel with `config.json`
|
||||
- **Privilege escalation via the admin pipe** — pipe ACL excludes non-admins
|
||||
- **Local user spoofing the source IP** — `X-Forwarded-For` is ignored unless you explicitly trust a proxy
|
||||
|
||||
What you're NOT protected against — these are out of scope for this server:
|
||||
|
||||
- Compromise of an admin account on the host (game over — they own everything)
|
||||
- A malicious script you configured (you wrote it; the server just runs it)
|
||||
- DoS via volume of requests — there's no rate limiting in v0.x
|
||||
- Memory dump of the running service revealing decrypted secrets — DPAPI protects at-rest only
|
||||
@@ -0,0 +1,122 @@
|
||||
# Recipe: GitHub-style HMAC-signed webhook
|
||||
|
||||
GitHub, Stripe, Slack, Shopify, and most SaaS providers sign their outbound webhooks with HMAC. The receiver computes the same HMAC over the request body using a shared secret and rejects the request if the signatures don't match. Webhook Server has this built in — you just point a real GitHub webhook at your endpoint.
|
||||
|
||||
## What we're building
|
||||
|
||||
A webhook URL that GitHub calls on every push to a repo. The server runs a PowerShell script that pulls the latest commit and triggers a deployment. Authentication is HMAC-SHA256 over the request body, using the secret you configured in GitHub's webhook settings.
|
||||
|
||||
## On the GitHub side
|
||||
|
||||
In your repo: **Settings → Webhooks → Add webhook**.
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Payload URL | `https://hooks.contoso.com/hook/gh-deploy` (yes, HTTPS — GitHub enforces it for public hosts) |
|
||||
| Content type | `application/json` |
|
||||
| Secret | Generate a long random string. Copy it for the next step. |
|
||||
| SSL verification | Enable |
|
||||
| Events | Just `push` |
|
||||
|
||||
Save. GitHub immediately delivers a `ping` event for testing. You'll see it in **Recent Deliveries** with whatever response code your server returns.
|
||||
|
||||
## The PowerShell deployment script
|
||||
|
||||
`C:\Scripts\gh-deploy.ps1`:
|
||||
|
||||
```powershell
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$payload = $input | ConvertFrom-Json
|
||||
|
||||
# Verify the event type via the X-GitHub-Event header passed as an env var
|
||||
$event = $env:WEBHOOK_HEADER_X_GITHUB_EVENT
|
||||
if ($event -eq 'ping') {
|
||||
"got ping from $($payload.repository.full_name)"
|
||||
return
|
||||
}
|
||||
if ($event -ne 'push') {
|
||||
Write-Error "ignoring $event event"
|
||||
}
|
||||
|
||||
$repo = $payload.repository.full_name
|
||||
$branch = $payload.ref -replace '^refs/heads/', ''
|
||||
$sha = $payload.after
|
||||
|
||||
if ($branch -ne 'main') {
|
||||
"ignoring push to $branch"
|
||||
return
|
||||
}
|
||||
|
||||
$repoDir = "C:\Deploys\$($payload.repository.name)"
|
||||
if (-not (Test-Path $repoDir)) {
|
||||
git clone "https://github.com/$repo.git" $repoDir
|
||||
}
|
||||
|
||||
Push-Location $repoDir
|
||||
try {
|
||||
git fetch --all
|
||||
git reset --hard $sha
|
||||
# ...your build/deploy steps here...
|
||||
& npm ci
|
||||
& npm run build
|
||||
Restart-Service MyAppService
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
"deployed $repo @ $sha"
|
||||
```
|
||||
|
||||
## Configure the endpoint
|
||||
|
||||
**File → New endpoint**:
|
||||
|
||||
| Section | Setting | Value |
|
||||
|---|---|---|
|
||||
| Identity | Slug | `gh-deploy` |
|
||||
| Auth | Mode | **HMAC** |
|
||||
| Auth | HMAC secret | paste the GitHub-side secret |
|
||||
| Auth | HMAC header | `X-Hub-Signature-256` *(GitHub's default)* |
|
||||
| Allowed clients | | `140.82.112.0/20`, `192.30.252.0/22` *(GitHub's webhook IP ranges; check [docs.github.com](https://api.github.com/meta) for the live list)* |
|
||||
| Executor | Type | **Windows PowerShell** |
|
||||
| Executor | Script path | `C:\Scripts\gh-deploy.ps1` |
|
||||
| Data passing | JSON body to stdin | ✓ |
|
||||
| Data passing | Headers/query as env vars | ✓ *(needed so `WEBHOOK_HEADER_X_GITHUB_EVENT` is set)* |
|
||||
| Run as | Identity | **Service** (default) — assumes the deployment is local |
|
||||
| Response | Mode | **Async** *(GitHub times out fast; don't make it wait for the build)* |
|
||||
| Response | Timeout (sec) | `600` |
|
||||
|
||||
Save.
|
||||
|
||||
## What HMAC does for you here
|
||||
|
||||
GitHub computes `sha256(body, secret)` and sends it as `sha256=<hex>` in `X-Hub-Signature-256`. Webhook Server computes the same hash, verifies in fixed time, and rejects (401) on mismatch.
|
||||
|
||||
This means:
|
||||
|
||||
- A request with a tampered body fails the check
|
||||
- A captured request can be **replayed verbatim** (the signature is valid for that body) — if that matters, GitHub also includes a `X-GitHub-Delivery` ID and timestamp you can deduplicate against
|
||||
- The secret never travels over the network — only the digest does, so HTTPS is for confidentiality of the body, not the secret
|
||||
|
||||
## Adapting for Stripe, Slack, etc.
|
||||
|
||||
Same pattern, different headers and signing details. The four HMAC fields in the editor cover all common variants:
|
||||
|
||||
| Provider | Header | Prefix | Encoding | Algorithm |
|
||||
|---|---|---|---|---|
|
||||
| GitHub | `X-Hub-Signature-256` | `sha256=` | hex | SHA-256 |
|
||||
| Stripe | `Stripe-Signature` | (none — but Stripe's format is multipart, see below) | hex | SHA-256 |
|
||||
| Slack | `X-Slack-Signature` | `v0=` | hex | SHA-256 |
|
||||
| Generic / custom | configurable | configurable | configurable | SHA-1 / SHA-256 / SHA-512 |
|
||||
|
||||
**Stripe** is special: their `Stripe-Signature` header has the format `t=<timestamp>,v1=<sig>,v0=<sig>`, where `v1` is HMAC-SHA256 of `<timestamp>.<body>`. Webhook Server's straight HMAC check doesn't match Stripe's signed-with-timestamp scheme. Workarounds:
|
||||
|
||||
- Use **Bearer auth** on Stripe webhooks instead, since you already control the secret
|
||||
- Or do unauthenticated + IP allowlist + a script-side signature check using their official validation library
|
||||
|
||||
For everything that's "GitHub-shaped" (signed body, raw HMAC), the built-in HMAC mode is the right pick.
|
||||
@@ -0,0 +1,68 @@
|
||||
# Recipe: Pop UI on the user's desktop
|
||||
|
||||
The classic "fire a hook from your phone, see a calculator window appear on your PC." Useful for:
|
||||
|
||||
- Triggering interactive installers / wizards
|
||||
- Opening browser tabs to specific dashboards on demand
|
||||
- Playing a sound / showing a toast notification
|
||||
- Demos and party tricks
|
||||
|
||||
## Why this is non-trivial on Windows
|
||||
|
||||
The Webhook Server service runs as `LocalSystem` in **session 0**. Anything launched normally from a Service-mode endpoint also lands in session 0, which has no visible desktop — UI runs but nobody sees it. To put a window on the desktop of whoever is logged in at the keyboard, the service has to:
|
||||
|
||||
1. Find the active console session ID (`WTSGetActiveConsoleSessionId`)
|
||||
2. Get a primary token for the user in that session (`WTSQueryUserToken`)
|
||||
3. Spawn the new process with `CreateProcessAsUser` against that token, targeting `winsta0\default`
|
||||
|
||||
Webhook Server does all of this for you when the endpoint's **Run as** is set to **InteractiveUser**.
|
||||
|
||||
## Configure the endpoint
|
||||
|
||||
| Section | Setting | Value |
|
||||
|---|---|---|
|
||||
| Identity | Slug | `calc` |
|
||||
| Identity | Description | "Pop calculator on the logged-in user's desktop" |
|
||||
| Auth | Mode | None / Bearer — your call |
|
||||
| Allowed clients | | restrict; this is interactive UI |
|
||||
| Executor | Type | **Executable** |
|
||||
| Executor | Executable path | `C:\Windows\System32\calc.exe` |
|
||||
| Run as | Identity | **InteractiveUser** |
|
||||
| Response | Mode | **Async** *(calc never exits on its own; sync would 30-second-timeout-kill it every time)* |
|
||||
| Response | Fail on non-zero exit | unticked |
|
||||
|
||||
Save. Hit `http://localhost:8080/hook/calc` from anywhere — calc.exe pops up on your desktop.
|
||||
|
||||
## Limits
|
||||
|
||||
- **Service must run as LocalSystem.** Only SYSTEM has the `SeTcbPrivilege` required for `WTSQueryUserToken`. If you switched the service to a gMSA (e.g. for AD-write hooks), this mode stops working. Run two instances of Webhook Server on different ports if you need both.
|
||||
- **Someone must be logged in** at the console. If the desktop is at the lock screen with no user signed in, the hook fails with `No active console session - is anyone logged in at the keyboard?`.
|
||||
- **RDP sessions complicate things.** `WTSGetActiveConsoleSessionId` always returns the *console* session, not RDP sessions. If only RDP users are connected and no one is at the physical keyboard, this mode fails. (A separate API, `WTSQueryUserToken` against an enumerated session ID, can target RDP — that'd be a v0.x feature request.)
|
||||
- **Multiple users logged in via fast-user-switching** — the hook lands in whichever session is currently active (the foreground desktop), not all of them.
|
||||
|
||||
## Variations
|
||||
|
||||
### Notification toast instead of a window
|
||||
|
||||
Use a PowerShell script that emits a Windows 10/11 toast via `BurntToast` (third-party module) or the built-in WinRT API:
|
||||
|
||||
```powershell
|
||||
# requires: Install-Module BurntToast
|
||||
New-BurntToastNotification -Text 'Webhook fired',$($input | Out-String)
|
||||
```
|
||||
|
||||
Configure the endpoint as InteractiveUser + WindowsPowerShell + inline command. The toast appears as the logged-in user — same as if they fired it themselves.
|
||||
|
||||
### Open a URL in the user's default browser
|
||||
|
||||
```powershell
|
||||
Start-Process ($input | ConvertFrom-Json).url
|
||||
```
|
||||
|
||||
Body: `{ "url": "https://contoso.servicenow.com/incident/123" }`
|
||||
|
||||
This opens the URL in whatever the user has set as default. Handy for "page on-call → they reply on their phone with a link → URL opens on their workstation when they sit down."
|
||||
|
||||
### Run a setup wizard / installer that needs UI
|
||||
|
||||
Some installers refuse to run silently or have steps that require human input. Wrap them as InteractiveUser hooks so the operator can trigger them from a help-desk console without having to RDP in.
|
||||
@@ -0,0 +1,243 @@
|
||||
# Recipe: Zerto failover post-script → DNS update + service checks
|
||||
|
||||
This is the canonical reason Webhook Server exists.
|
||||
|
||||
When Zerto fails a VM over from production to DR, the VM boots fine — but **the things around it** often need attention: DNS records still point at the production IP, dependent services need to be checked, on-call needs a heads-up. Zerto pre/post scripts run on the **Zerto Virtual Manager**, not on a domain controller and not necessarily with admin rights to the things that need fixing. So you want a single webhook URL that the post-script hits, and a Windows host on the DR side that does the actual work with the right identity.
|
||||
|
||||
## What we're building
|
||||
|
||||
Zerto's post-recovery script (a one-shot PowerShell file pointing at curl) calls `http://webhook.dr.contoso.local:8080/hook/post-failover` with a JSON body identifying the VPG and operation. The Webhook Server, running on a DR-side Windows host as a gMSA with delegated AD/DNS rights, runs PowerShell that:
|
||||
|
||||
1. Updates 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
|
||||
|
||||
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.
|
||||
|
||||
## Why curl and not Invoke-WebRequest?
|
||||
|
||||
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.
|
||||
|
||||
## 1. The Zerto post-script (client side)
|
||||
|
||||
A ready-to-use script ships in this repo at [`scripts/examples/zerto-post-failover.ps1`](../../scripts/examples/zerto-post-failover.ps1). Copy it to the ZVM, edit `$WebhookUrl` and the bearer-token path at the top, and wire it into the VPG:
|
||||
|
||||
> **VPG settings → Recovery → Scripts → Post-Recovery Script**
|
||||
> Path: `C:\Scripts\zerto-post-failover.ps1`
|
||||
> Parameters: *(leave empty)*
|
||||
|
||||
The script is ~50 lines and only depends on `curl.exe` + a token file readable by the ZVM service account.
|
||||
|
||||
The flow:
|
||||
|
||||
```
|
||||
Zerto VPG failover starts
|
||||
|
|
||||
+-- VM is brought up at DR site
|
||||
|
|
||||
+-- Zerto post-script fires:
|
||||
| curl POST http://webhook.dr/hook/post-failover (async, returns 202 in ~50ms)
|
||||
|
|
||||
+-- Zerto sees success, finishes the failover and reports done
|
||||
|
|
||||
(meanwhile, on the webhook server)
|
||||
|
|
||||
running PowerShell for several minutes:
|
||||
- update DNS
|
||||
- wait for VM ready
|
||||
- check services on VM
|
||||
- notify Teams
|
||||
```
|
||||
|
||||
## 2. The server-side script (does the actual work)
|
||||
|
||||
Save this on the webhook host as `C:\Scripts\post-failover-handler.ps1`:
|
||||
|
||||
```powershell
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$body = $input | ConvertFrom-Json
|
||||
|
||||
# ---------- environment specifics; edit for your site ----------
|
||||
$dnsServer = 'dc01.contoso.local'
|
||||
$forwardZone = 'contoso.local'
|
||||
$teamsWebhook = 'https://contoso.webhook.office.com/...'
|
||||
$drIpMap = @{
|
||||
'app01' = '10.42.10.11'
|
||||
'app02' = '10.42.10.12'
|
||||
'db01' = '10.42.10.21'
|
||||
}
|
||||
$serviceMap = @{
|
||||
'app01' = @('W3SVC','MyAppSvc')
|
||||
'app02' = @('W3SVC','MyAppSvc')
|
||||
'db01' = @('MSSQLSERVER','SQLAgent')
|
||||
}
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
# Default the VM list to "all VMs we know about" if the post-script didn't
|
||||
# tell us, so the same handler works without having to embed the VM list in
|
||||
# every Zerto post-script.
|
||||
$vms = if ($body.vms) { $body.vms } else { $drIpMap.Keys }
|
||||
|
||||
$summary = @()
|
||||
|
||||
foreach ($vm in $vms) {
|
||||
if (-not $drIpMap.ContainsKey($vm)) {
|
||||
$summary += "skip $vm (no DR IP mapping in handler)"
|
||||
continue
|
||||
}
|
||||
$ip = $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
|
||||
}
|
||||
|
||||
# 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")
|
||||
} | ConvertTo-Json
|
||||
try {
|
||||
Invoke-RestMethod -Uri $teamsWebhook -Method POST -ContentType 'application/json' -Body $teamsBody | Out-Null
|
||||
} catch {
|
||||
$summary += "teams! notification failed: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
# Return the summary so it shows up in the webhook log + outbound callback
|
||||
$summary -join "`n"
|
||||
```
|
||||
|
||||
Two things to call 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.
|
||||
|
||||
## 3. Configure the endpoint in the GUI
|
||||
|
||||
**File → New endpoint:**
|
||||
|
||||
| Section | Setting | Value |
|
||||
|---|---|---|
|
||||
| Identity | Slug | `post-failover` |
|
||||
| Identity | Description | "Zerto post-recovery: DNS + service checks" |
|
||||
| Auth | Mode | **Bearer** |
|
||||
| Auth | Bearer secret | generate a 32-byte random string; copy it for the Zerto script's token file |
|
||||
| Allowed clients | (one per line) | `10.0.0.0/8` *(your ZVM's network)* |
|
||||
| Executor | Type | **Windows PowerShell** |
|
||||
| Executor | Script path | `C:\Scripts\post-failover-handler.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)* |
|
||||
|
||||
Save. Right-click the row → **Copy URL** to grab `http://webhook.dr.contoso.local:8080/hook/post-failover` and paste it into `$WebhookUrl` at the top of the Zerto-side script.
|
||||
|
||||
> **Why Bearer instead of HMAC?** Both work. Bearer is simpler — drop the token in a file on the ZVM that's readable by the ZVM service account and you're done. HMAC requires the Zerto-side script to compute a signature, which is doable but adds a few lines of code. Pick what fits your environment.
|
||||
|
||||
## 4. Wire up the bearer token
|
||||
|
||||
Place the bearer token in a file the ZVM service account can read (and nobody else):
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
Adjust the service principal name to whatever Zerto runs as on your version. The script reads from this path automatically; no change needed in the script itself.
|
||||
|
||||
## 5. Test before going live
|
||||
|
||||
In a maintenance window, fire the webhook by hand:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
You'll get back `{"runId":"…","accepted":true}` immediately. Open the Webhook Server GUI and watch the log panel — within 30 seconds or so you'll see lines for the run. Confirm DNS records updated, services on each VM ended in `Running`, and the Teams notification arrived.
|
||||
|
||||
## Variations
|
||||
|
||||
### Different actions for failover vs. failback
|
||||
|
||||
Pass an `operation` field in the body and branch on it. The Zerto-side script already sends `operation = 'failover'`. Add a separate post-failback script (or detect from `$env:ZertoOperationType`) that sends `operation = 'failback'` and have the handler revert DNS to production IPs.
|
||||
|
||||
### Per-VPG endpoints
|
||||
|
||||
If you want fine-grained access control or different actions per VPG, create one endpoint per VPG (`post-failover-app`, `post-failover-db`, …) and give each its own bearer token. The GUI handles dozens of endpoints fine.
|
||||
|
||||
### Audit trail to a SIEM
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,277 @@
|
||||
# Recipe: Zerto ZVMA (Kubernetes) pre/post scripts → notify + VM health check
|
||||
|
||||
> Companion to [Zerto failover post-script → DNS + service checks](zerto-pre-post-scripts.md).
|
||||
> That recipe targets the **Windows ZVM** (the older deployment, where the
|
||||
> Zerto-side script is a `.ps1` calling `curl.exe`). **This** recipe targets
|
||||
> the **ZVMA on Kubernetes** — the newer deployment, where pre/post scripts
|
||||
> run inside the in-cluster `scripts-service` container (Linux + pwsh 7).
|
||||
> The webhook-server side is the same Windows service in both cases; only
|
||||
> the Zerto-side runtime differs.
|
||||
|
||||
## What we're building
|
||||
|
||||
ZVMA's `scripts-service` pod runs your VPG pre/post scripts inside a Linux
|
||||
container. It exposes a small set of `Zerto*` environment variables, and we
|
||||
want to:
|
||||
|
||||
1. POST those variables to a Webhook Server endpoint at the start (pre) and
|
||||
end (post) of every VPG operation, and
|
||||
2. On the receiving Windows host, do something useful with them — at minimum
|
||||
a chat notification, and on `post` a quick health check of the VMs that
|
||||
just powered on.
|
||||
|
||||
The endpoints are **Async**, so the Zerto VPG sequence is never blocked by
|
||||
slow downstream actions (notifications, port probes, etc.).
|
||||
|
||||
```
|
||||
Zerto VPG operation starts
|
||||
|
|
||||
+-- ZVMA scripts-service container runs:
|
||||
| /app/scripts-files/zerto-zvma-send.ps1 -Phase pre
|
||||
| -> POST http://webhook.dr/hook/zerto-pre (async, returns 202)
|
||||
|
|
||||
+-- VMs come up at recovery site
|
||||
|
|
||||
+-- ZVMA scripts-service container runs:
|
||||
/app/scripts-files/zerto-zvma-send.ps1 -Phase post
|
||||
-> POST http://webhook.dr/hook/zerto-post (async, returns 202)
|
||||
|
||||
(meanwhile, on the webhook server)
|
||||
/hook/zerto-pre -> Slack/Teams notification ("Test failover starting...")
|
||||
/hook/zerto-post -> Slack/Teams notification + ping/port probe each VM,
|
||||
write a JSON report to disk, exit non-zero on failure.
|
||||
```
|
||||
|
||||
## What ZVMA exposes
|
||||
|
||||
Captured from a real Test failover; same set is present in pre and post:
|
||||
|
||||
| Variable | Example | Notes |
|
||||
|---|---|---|
|
||||
| `ZertoVPGName` | `ubuntu-2404-local` | The VPG that fired the script |
|
||||
| `ZertoInternalVpgName` | `ubuntu-2404-local` | Usually identical to `ZertoVPGName` |
|
||||
| `ZertoOperation` | `Test` | `Test` / `Failover` / `Move` / `FailoverBeforeCommit` / `FailoverDuringCommit` |
|
||||
| `ZertoForce` | `Yes` (pre) / `No` (post) | Set to `Yes` only during the pre phase when force mode is on; reset to `No` by post |
|
||||
| `VmDisplayNames` | `ubuntu-2404(1)(1)(1)` | Comma-separated for multi-VM VPGs; Test failovers add `(N)` suffixes |
|
||||
| `ZertoHypervisorManagerIP` | `192.168.50.20` | The vCenter / Hyper-V manager ZVMA is talking to |
|
||||
| `ZertoHypervisorManagerPort` | `443` | |
|
||||
| `ZertoOutputDir` | `/app/scripts-output` | Container-side output dir (written back to ZVMA via PVC) |
|
||||
| `ZertoWorkingDir` | `/app/scripts-files` | Where script files live in-container |
|
||||
|
||||
Branch on `ZertoOperation` to differentiate Test runs from real failovers.
|
||||
**`ZertoForce` is only meaningful during the pre phase** — capture it there
|
||||
if you need it later, because by post it's been reset.
|
||||
|
||||
## 1. The Zerto-side script (sender)
|
||||
|
||||
A ready-to-use script ships in this repo at
|
||||
[`scripts/examples/zerto-zvma-send.ps1`](../../scripts/examples/zerto-zvma-send.ps1).
|
||||
Place it where the `scripts-service` pod can read it — typically the
|
||||
`scripts-service-scripts-files-pvc`, mounted at `/app/scripts-files/` — and
|
||||
wire it into the VPG twice:
|
||||
|
||||
> **VPG settings → Recovery → Scripts → Pre-Recovery Script**
|
||||
> Path: `/app/scripts-files/zerto-zvma-send.ps1`
|
||||
> Parameters: `-Phase pre`
|
||||
>
|
||||
> **VPG settings → Recovery → Scripts → Post-Recovery Script**
|
||||
> Path: `/app/scripts-files/zerto-zvma-send.ps1`
|
||||
> Parameters: `-Phase post`
|
||||
|
||||
The default `$WebhookUrl` includes `{phase}` so one script + one URL config
|
||||
serves both phases — `http://webhook.dr/hook/zerto-{phase}` becomes
|
||||
`/hook/zerto-pre` and `/hook/zerto-post` automatically. Override with
|
||||
`-WebhookUrl` and `-Bearer` if you'd rather pass them per-VPG.
|
||||
|
||||
The script POSTs a single JSON object:
|
||||
|
||||
```json
|
||||
{
|
||||
"phase": "pre",
|
||||
"capturedAt": "2026-05-08T17:45:54Z",
|
||||
"host": "scripts-service-f9b6cb7-4xbxq",
|
||||
"zerto": {
|
||||
"vpgName": "ubuntu-2404-local",
|
||||
"internalVpgName": "ubuntu-2404-local",
|
||||
"operation": "Test",
|
||||
"force": "Yes",
|
||||
"vmDisplayNames": "ubuntu-2404(1)(1)(1)",
|
||||
"hypervisorManagerIP": "192.168.50.20",
|
||||
"hypervisorManagerPort": "443",
|
||||
"outputDir": "/app/scripts-output",
|
||||
"workingDir": "/app/scripts-files"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
A webhook outage **does not fail the VPG** — the script catches and exits 0.
|
||||
Comment in the file shows how to flip that to strict mode if you'd rather a
|
||||
webhook outage abort the failover.
|
||||
|
||||
## 2. The webhook-server-side scripts (receivers)
|
||||
|
||||
Two examples ship in the repo. Both read the JSON body from stdin (the
|
||||
webhook server delivers the body to the script's stdin when **JSON body to
|
||||
stdin** is ticked on the endpoint).
|
||||
|
||||
### a. Slack/Teams notification — both phases
|
||||
|
||||
[`scripts/examples/zerto-receiver-notify.ps1`](../../scripts/examples/zerto-receiver-notify.ps1)
|
||||
posts a single-line summary to a Slack or Teams Incoming Webhook URL. It
|
||||
picks an icon based on `ZertoOperation`:
|
||||
|
||||
- `Test` → 🧪 — benign, expected
|
||||
- `Failover` → 🚨 — real production event
|
||||
- `Move` → 🚚 — planned migration
|
||||
|
||||
…and highlights `ZertoForce=Yes` on the **pre** message so you can see at
|
||||
a glance whether the operation was force-flagged.
|
||||
|
||||
Set the destination via `NOTIFY_URL` env var on the webhook host, or
|
||||
hardcode at the top of the script.
|
||||
|
||||
### b. Post-recovery VM health check — post phase only
|
||||
|
||||
[`scripts/examples/zerto-receiver-vm-healthcheck.ps1`](../../scripts/examples/zerto-receiver-vm-healthcheck.ps1)
|
||||
runs only on `phase=post` for operations that bring VMs up
|
||||
(`Test`/`Failover`/`Move`/`FailoverBeforeCommit`/`FailoverDuringCommit`).
|
||||
For each name in `VmDisplayNames` it:
|
||||
|
||||
1. Strips the trailing `(1)(1)(1)` suffix Zerto adds on Test failovers, so
|
||||
DNS resolution targets the actual hostname.
|
||||
2. Pings (`Test-Connection`).
|
||||
3. Probes a configurable TCP port (`-ProbePort`, default `3389` for RDP;
|
||||
use `22` for SSH or `443` for the web tier).
|
||||
4. Writes a JSON report to
|
||||
`C:\ProgramData\WebhookServer\zerto-healthchecks\<vpg>-<op>-<utcstamp>.json`.
|
||||
5. Exits non-zero if any VM failed either probe — which surfaces in the
|
||||
webhook server's run history (and outbound callback, if configured).
|
||||
|
||||
Bump the endpoint's **Timeout (sec)** to `120` when wiring this in, since
|
||||
network probes can take a while.
|
||||
|
||||
## 3. Configure the endpoints in the GUI
|
||||
|
||||
Two endpoints. Identical except for the slug, the script, and (for the
|
||||
healthcheck) the timeout.
|
||||
|
||||
### `zerto-pre`
|
||||
|
||||
| Section | Setting | Value |
|
||||
|---|---|---|
|
||||
| Identity | Slug | `zerto-pre` |
|
||||
| Identity | Description | "Zerto pre-recovery: chat notification" |
|
||||
| Auth | Mode | **Bearer** |
|
||||
| Auth | Bearer secret | generate a 32-byte random string; reuse for `zerto-post` |
|
||||
| Allowed clients | (one per line) | the IP of the K8s node running `scripts-service` (e.g. `192.168.50.30`) |
|
||||
| Executor | Type | **Windows PowerShell** (or PowerShell 7) |
|
||||
| Executor | Script path | `C:\scripts\zerto-receiver-notify.ps1` |
|
||||
| Data passing | JSON body to stdin | ✓ |
|
||||
| Run as | Identity | **Service** |
|
||||
| Response | Mode | **Async** |
|
||||
| Response | Timeout (sec) | `30` |
|
||||
| Response | Fail on non-zero exit | unticked *(async hooks have no caller to receive a 502)* |
|
||||
|
||||
### `zerto-post`
|
||||
|
||||
Same as above, except:
|
||||
|
||||
| Setting | Value |
|
||||
|---|---|
|
||||
| Slug | `zerto-post` |
|
||||
| Description | "Zerto post-recovery: notify + VM health check" |
|
||||
| Script path | a **wrapper** that calls both receiver scripts in turn (see below) |
|
||||
| Timeout (sec) | `120` |
|
||||
|
||||
Two receivers on one endpoint is easiest with a tiny wrapper that fans
|
||||
stdin out to both scripts:
|
||||
|
||||
```powershell
|
||||
# C:\scripts\zerto-post-fanout.ps1
|
||||
$body = [Console]::In.ReadToEnd()
|
||||
$body | & 'C:\scripts\zerto-receiver-notify.ps1'
|
||||
$body | & 'C:\scripts\zerto-receiver-vm-healthcheck.ps1'
|
||||
```
|
||||
|
||||
Or run the two as separate endpoints (`zerto-post-notify` and
|
||||
`zerto-post-healthcheck`) and have the Zerto-side script POST to both —
|
||||
either pattern is fine. The fanout wrapper keeps the Zerto config simpler.
|
||||
|
||||
## 4. Wire up the bearer token
|
||||
|
||||
On the ZVMA / scripts-service side, the easiest place to put the token is
|
||||
a Kubernetes Secret mounted into the pod, but the simplest approach for
|
||||
testing is to pass it as a parameter to the Zerto-side script:
|
||||
|
||||
> VPG settings → Pre-Recovery Script → Parameters:
|
||||
> `-Phase pre -Bearer <paste-token>`
|
||||
>
|
||||
> VPG settings → Post-Recovery Script → Parameters:
|
||||
> `-Phase post -Bearer <paste-token>`
|
||||
|
||||
For production, mount a Secret at a known path in the pod and have the
|
||||
sender script read from it (`Get-Content /run/secrets/webhook-token`).
|
||||
|
||||
## 5. Test before going live
|
||||
|
||||
Run a Test failover on a non-critical VPG. Watch:
|
||||
|
||||
- **Slack/Teams**: a `:test_tube: Zerto Test - phase: pre` message arrives,
|
||||
followed ~30s–several minutes later by a `:test_tube: Zerto Test - phase:
|
||||
post` message.
|
||||
- **Webhook Server GUI** → run history: two runs for `zerto-pre` /
|
||||
`zerto-post`, both green.
|
||||
- **`C:\ProgramData\WebhookServer\zerto-healthchecks\`**: a fresh JSON
|
||||
report named `<vpg>-Test-<utcstamp>.json` containing per-VM ping and port
|
||||
probe results.
|
||||
- **ZVMA**: the VPG operation completes successfully; nothing in the
|
||||
pre/post logs blocked on the webhook.
|
||||
|
||||
## Variations
|
||||
|
||||
### Branch on Test vs. real failover in the receivers
|
||||
|
||||
The notifier already styles the message differently. To do something only
|
||||
on a real failover (e.g. update DNS), guard with:
|
||||
|
||||
```powershell
|
||||
if ($p.zerto.operation -ne 'Test') {
|
||||
# do the destructive thing
|
||||
}
|
||||
```
|
||||
|
||||
A `ZertoOperation` of `Test` means "exercise — don't touch production
|
||||
dependencies." Always check it before doing anything that mutates real
|
||||
state.
|
||||
|
||||
### Capture `ZertoForce` from pre for use in post
|
||||
|
||||
`ZertoForce` is `Yes` only during the **pre** phase when force mode is on
|
||||
and is reset to `No` by the **post** phase. If your post-side logic needs
|
||||
to know the operation was force-flagged, save it during pre (e.g. write a
|
||||
small marker to the shared `ZertoOutputDir`) and read it back during post.
|
||||
|
||||
### Per-VPG endpoints
|
||||
|
||||
For fine-grained access control or different actions per VPG, create one
|
||||
endpoint per VPG (`zerto-pre-app01`, `zerto-post-app01`, …) with its own
|
||||
bearer token. Override `-WebhookUrl` and `-Bearer` on the Zerto side per
|
||||
VPG.
|
||||
|
||||
### Audit trail
|
||||
|
||||
Every endpoint can have an outbound **Callback** URL. Configure with your
|
||||
SIEM's HTTP collector + an HMAC secret, and every run produces a JSON
|
||||
record with runId, exit code, duration, stdout, and stderr — convenient
|
||||
for compliance.
|
||||
|
||||
## Security note
|
||||
|
||||
The ZVMA `scripts-service` pod runs your scripts inside a Linux container
|
||||
with broad reach into the management cluster — anything your script does
|
||||
runs with whatever ServiceAccount that pod uses. Treat the script content
|
||||
as privileged and make sure pre/post script edit rights are restricted to
|
||||
trusted operators. If you're unfamiliar with the pod's RBAC posture, check
|
||||
`Get-ChildItem Env:` from inside the container and look at
|
||||
`/var/run/secrets/kubernetes.io/serviceaccount/` — that token is what your
|
||||
scripts (and a malicious script) can use to talk to the K8s API.
|
||||
@@ -0,0 +1,78 @@
|
||||
# Run As modes — when to use which
|
||||
|
||||
Each endpoint has a **Run As** setting (in the editor's "Run as" section) that controls *who* the script runs as. The default works for most cases, and switching modes is one dropdown change.
|
||||
|
||||
## The three modes
|
||||
|
||||
| Mode | Runs as | Use when… |
|
||||
|---|---|---|
|
||||
| **Service** *(default)* | Whoever the Windows Service runs under (LocalSystem by default) | Almost everything. Local file ops, calling local APIs, running cmd / PowerShell scripts that don't need a user identity. |
|
||||
| **InteractiveUser** | The user logged in at the keyboard | The script needs to put a window on the screen (Calculator, a notification dialog, opening a browser tab) |
|
||||
| **SpecificUser** | A named local or domain user / password you provide | The script runs in AD, a fileshare, or any system that wants the action attributed to a specific identity — and you don't want the service itself running as that user. |
|
||||
|
||||
## Service (default)
|
||||
|
||||
Nothing to configure. The hook runs as `LocalSystem` by default — full local rights, very limited network identity (the machine account on a domain).
|
||||
|
||||
You can change the service identity at install time via the `-ServiceAccount` parameter to `install-service.ps1` (gMSA, domain user, etc.). Anything you set there applies to **all** Service-mode endpoints. See [Service account & Active Directory](service-account-and-ad.md).
|
||||
|
||||
**Pros**: zero config per endpoint, no passwords to manage, fastest path
|
||||
**Cons**: the script can't pop UI on the user's desktop (Session 0 isolation), and on a workgroup machine it has no domain identity at all
|
||||
|
||||
## InteractiveUser
|
||||
|
||||
Pick this when the hook should appear visually on the desktop of whoever is logged in. The clearest example is "fire a hook from my phone, get a Calculator window on my PC."
|
||||
|
||||
How it works internally: the service (running as SYSTEM) calls the Win32 API `WTSQueryUserToken` to grab the active console session's user token, then `CreateProcessAsUser` to land the new process inside that session.
|
||||
|
||||
What you don't have to configure: username, password, profile loading, session ID. All inferred at runtime.
|
||||
|
||||
What can go wrong:
|
||||
|
||||
- **No one logged in** at the keyboard → hook fails with `No active console session - is anyone logged in at the keyboard?`. The hook can't run; there's no desktop to land on.
|
||||
- **Service runs as anything other than LocalSystem** → `WTSQueryUserToken` requires SYSTEM. If you switched the service to a gMSA / domain user, InteractiveUser stops working.
|
||||
- **Locked desktop, no user logged in but session 1 reserved** → similar to "no one logged in." Once a user logs in interactively (even just to the lock screen with credentials cached), the session is "active enough" for this to work.
|
||||
|
||||
**Use case examples**: see [recipes/ui-on-desktop.md](recipes/ui-on-desktop.md).
|
||||
|
||||
## SpecificUser
|
||||
|
||||
Pick this when the hook needs to authenticate as a specific account — a service account with delegated AD rights, a local Administrator on a remote machine, etc. — but you don't want the *whole service* running as that account.
|
||||
|
||||
Configure:
|
||||
|
||||
- **Username**: `DOMAIN\user`, `.\local-user`, or a UPN like `user@contoso.com`. The leading `.\` is shorthand for the local machine.
|
||||
- **Password**: stored DPAPI-encrypted at rest. Visible in plaintext in the GUI for an admin user, by design — anyone with admin pipe access already has SYSTEM-equivalent rights.
|
||||
- **Load profile**: optional. Loads the user's HKCU and AppData before running. Slower (~1s extra). Only needed if the script reads user-scoped settings (uncommon).
|
||||
|
||||
How it works internally: the service calls `LogonUser` with the credentials (interactive logon type first, falls back to batch logon for service-only accounts), then `DuplicateTokenEx` + `CreateProcessAsUser`. The script lands in a fresh batch session with the user's network identity.
|
||||
|
||||
> **Why not `psi.UserName` / `psi.Password` like a normal .NET app?** Because `CreateProcessWithLogonW` (what those properties use under the hood) refuses to run when the caller is `LocalSystem`, which is exactly our scenario. The token-based path is the documented Windows mechanism for this.
|
||||
|
||||
What can go wrong:
|
||||
|
||||
- **Wrong password** → log shows `LogonUser (DOMAIN\user) failed - The user name or password is incorrect`. Re-enter in the editor.
|
||||
- **Account is denied logon locally** → log shows `Logon failure: the user has not been granted the requested logon type`. Make sure the account has at least one of *Log on as a batch job* or *Log on locally* under `secpol.msc` → Local Policies → User Rights Assignment.
|
||||
- **Domain controller unreachable** → for domain accounts, the service must be able to reach a DC. For local accounts (`.\name`), no domain dependency.
|
||||
|
||||
## Decision flowchart
|
||||
|
||||
```
|
||||
Need UI on the user's desktop?
|
||||
│
|
||||
┌─────── yes ─────┴────── no ─────┐
|
||||
│ │
|
||||
InteractiveUser Need specific identity (AD / fileshare / etc.)?
|
||||
│
|
||||
┌──── yes ────┴──── no ────┐
|
||||
│ │
|
||||
Should ALL hooks run as Service
|
||||
this identity?
|
||||
│
|
||||
┌────── yes ──────────┴───────── no ──────────┐
|
||||
│ │
|
||||
Run service itself SpecificUser per endpoint
|
||||
as that account
|
||||
(gMSA / domain user)
|
||||
see service-account-and-ad.md
|
||||
```
|
||||
@@ -0,0 +1,149 @@
|
||||
# Service account & Active Directory
|
||||
|
||||
The service runs as `LocalSystem` out of the box. That's right for local-only scripts and **read-only** AD queries (LocalSystem authenticates to the network as the machine account, which Authenticated Users includes by default). It is wrong for hooks that need to **modify** AD — passwords, group memberships, computer objects.
|
||||
|
||||
This page covers the four real-world choices and how to switch.
|
||||
|
||||
## The four options
|
||||
|
||||
| Account | Network identity | When to use |
|
||||
|---|---|---|
|
||||
| **`LocalSystem`** *(default)* | Computer account `DOMAIN\MACHINE$` on a domain-joined host; nothing on a workgroup host | Default. Local file ops, simple PowerShell, read-only AD queries. Most powerful local account — any hook running under it has full local rights. |
|
||||
| **`LocalService`** | None | **Don't.** Cannot talk to a domain controller. Listed only to rule it out. |
|
||||
| **`NetworkService`** | Same machine account as LocalSystem | Slightly less local privilege than LocalSystem, same network identity. Rarely the right pick. |
|
||||
| **Domain user** (`DOMAIN\svc-webhookserver`) | That user | Use when hooks need write access to AD and you can't use a gMSA. You own password rotation. |
|
||||
| **gMSA** (`DOMAIN\svc-webhookserver$`) | That gMSA | **Recommended for AD-write workloads.** AD generates and rotates the password automatically every 30 days. Requires domain functional level 2012+. |
|
||||
|
||||
## Switching the service account at install time
|
||||
|
||||
Pass `-ServiceAccount` to `install-service.ps1` (or to the deploy / dev launcher):
|
||||
|
||||
```powershell
|
||||
# Domain user
|
||||
& "C:\Program Files\WebhookServer\scripts\install-service.ps1" `
|
||||
-BinaryPath "C:\Program Files\WebhookServer\WebhookServer.Service.exe" `
|
||||
-ServiceAccount "CONTOSO\svc-webhookserver" -Password "..."
|
||||
|
||||
# gMSA - note trailing $ and no -Password
|
||||
& "C:\Program Files\WebhookServer\scripts\install-service.ps1" `
|
||||
-BinaryPath "C:\Program Files\WebhookServer\WebhookServer.Service.exe" `
|
||||
-ServiceAccount 'CONTOSO\svc-webhookserver$'
|
||||
```
|
||||
|
||||
Or do it manually with `sc.exe` if the service is already installed:
|
||||
|
||||
```powershell
|
||||
sc.exe stop WebhookServer
|
||||
sc.exe config WebhookServer obj= 'CONTOSO\svc-webhookserver$'
|
||||
sc.exe start WebhookServer
|
||||
```
|
||||
|
||||
## gMSA setup (recommended for AD writes)
|
||||
|
||||
A gMSA is a Group Managed Service Account. Active Directory generates and stores its password and rotates it every 30 days; the host machine account retrieves the password as needed. You never see or store it. This is the cleanest pattern for production.
|
||||
|
||||
### One-time domain setup
|
||||
|
||||
If your domain has never used gMSAs, create the KDS root key (only needed once per domain):
|
||||
|
||||
```powershell
|
||||
# from a Domain Admin PowerShell, on any DC
|
||||
Add-KdsRootKey -EffectiveImmediately
|
||||
# in production wait 10 hours for replication; in a lab, override:
|
||||
# Add-KdsRootKey -EffectiveTime ((Get-Date).AddHours(-10))
|
||||
```
|
||||
|
||||
### Create the gMSA
|
||||
|
||||
```powershell
|
||||
# from a DC, with AD PowerShell module loaded
|
||||
New-ADServiceAccount -Name svc-webhookserver `
|
||||
-DNSHostName webhook01.contoso.local `
|
||||
-PrincipalsAllowedToRetrieveManagedPassword "DOMAIN\WebhookHosts"
|
||||
```
|
||||
|
||||
`PrincipalsAllowedToRetrieveManagedPassword` is the security group containing the computer accounts allowed to use the gMSA. Add your webhook host(s) to that group:
|
||||
|
||||
```powershell
|
||||
Add-ADGroupMember -Identity 'WebhookHosts' -Members 'WEBHOOK01$'
|
||||
# the host needs to reboot OR have its kerberos ticket flushed for the new group membership to apply
|
||||
```
|
||||
|
||||
### Install the gMSA on the host
|
||||
|
||||
On the webhook server machine itself:
|
||||
|
||||
```powershell
|
||||
# from elevated PowerShell, AD PowerShell module installed (RSAT)
|
||||
Install-ADServiceAccount svc-webhookserver
|
||||
Test-ADServiceAccount svc-webhookserver # should return True
|
||||
```
|
||||
|
||||
If `Test-ADServiceAccount` returns False, check:
|
||||
|
||||
- Host is in the `WebhookHosts` group (or whoever's in `PrincipalsAllowedToRetrieveManagedPassword`)
|
||||
- Host has been rebooted since being added to the group
|
||||
- KDS root key has had time to propagate (10 hours by default)
|
||||
|
||||
### Configure the service to use it
|
||||
|
||||
```powershell
|
||||
# from elevated PowerShell on the webhook host
|
||||
sc.exe stop WebhookServer
|
||||
sc.exe config WebhookServer obj= 'CONTOSO\svc-webhookserver$'
|
||||
sc.exe start WebhookServer
|
||||
```
|
||||
|
||||
Note the trailing `$`. There is **no password parameter** for gMSAs. The trailing `$` is what tells the SCM "look up this account in AD as a managed service account, retrieve its password automatically."
|
||||
|
||||
### Grant AD permissions
|
||||
|
||||
Give the gMSA only what it needs. For a typical "reset user passwords" workload:
|
||||
|
||||
```powershell
|
||||
# Delegate "Reset password and force change at next logon" on a specific OU
|
||||
$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"
|
||||
```
|
||||
|
||||
…or use the GUI Delegation of Control wizard in Active Directory Users and Computers.
|
||||
|
||||
## Domain user (fallback when gMSA isn't available)
|
||||
|
||||
```powershell
|
||||
# 1. Create the user (one time)
|
||||
New-ADUser -Name "svc-webhookserver" -SamAccountName "svc-webhookserver" `
|
||||
-AccountPassword (Read-Host -AsSecureString "password") -Enabled $true `
|
||||
-PasswordNeverExpires $true -CannotChangePassword $true
|
||||
|
||||
# 2. Grant "Log on as a service" right on the host:
|
||||
# secpol.msc -> Local Policies -> User Rights Assignment -> Log on as a service
|
||||
# Add CONTOSO\svc-webhookserver
|
||||
|
||||
# 3. Configure the service:
|
||||
sc.exe config WebhookServer obj= "CONTOSO\svc-webhookserver" password= "..."
|
||||
```
|
||||
|
||||
You own password rotation. When you change the password in AD, also update the service via `sc.exe config WebhookServer password= "newpw"` and restart it.
|
||||
|
||||
## What changes for hooks when you switch the service account
|
||||
|
||||
- **Service mode hooks** now run as the new account. PowerShell `whoami` from inside a hook will show the new identity.
|
||||
- **InteractiveUser hooks stop working** if you switch off LocalSystem. Only SYSTEM can call `WTSQueryUserToken`. If you need both AD-write hooks and UI-on-desktop hooks, pick one of:
|
||||
- Keep service as LocalSystem and use **SpecificUser** mode for AD-write hooks
|
||||
- Switch service to a gMSA / domain user and drop UI hooks (or move them to a separate Webhook Server instance running as LocalSystem)
|
||||
- **SpecificUser hooks** continue to work regardless. They use a separate `LogonUser` token per call.
|
||||
|
||||
## Verifying the switch worked
|
||||
|
||||
After changing the service account, restart the service and add a quick diagnostic endpoint:
|
||||
|
||||
```
|
||||
slug: whoami
|
||||
auth: none
|
||||
executor: Windows PowerShell
|
||||
inline command: whoami; whoami /groups
|
||||
```
|
||||
|
||||
Hit it and verify the output matches the account you configured. The first line should be `domain\svc-webhookserver` (or `domain\machine$` for LocalSystem on a domain-joined host).
|
||||
@@ -0,0 +1,136 @@
|
||||
# Troubleshooting
|
||||
|
||||
This page indexes the most common ways things go wrong, where to look, and what to do.
|
||||
|
||||
## Where to look first
|
||||
|
||||
| Symptom | First check |
|
||||
|---|---|
|
||||
| GUI shows "Disconnected" | Service running? `Get-Service WebhookServer` |
|
||||
| Hook returns 404 | Slug typo, or endpoint disabled |
|
||||
| Hook returns 401 | Auth header / signature mismatch |
|
||||
| Hook returns 403 | IP allowlist denies the caller |
|
||||
| Hook returns 200 but nothing happens | Response is the script's stdout — check exit code, stderr |
|
||||
| Hook returns 502 | Script ran and exited non-zero. Body has stderr. |
|
||||
| Hook returns 500 | Launch error (script not found, invalid path) |
|
||||
| Hook hangs | Timeout reached, or script is waiting on stdin |
|
||||
| Calc / UI doesn't appear despite InteractiveUser | See [Run As modes](runas-modes.md) — common pitfalls |
|
||||
|
||||
## Where the logs are
|
||||
|
||||
`C:\ProgramData\WebhookServer\logs\webhook-YYYYMMDD.log` — daily rolling, 14-day retention by default.
|
||||
|
||||
Every webhook run logs:
|
||||
- `[INF] Run <id> <slug> ok exit=0 dur=<ms>ms stdout=...` on success
|
||||
- `[WRN] Run <id> <slug> non-zero exit=<n> dur=<ms>ms stdout=... stderr=...` on script failure
|
||||
- `[WRN] Run <id> <slug> failed to launch: <reason>` on launch failure
|
||||
- `[WRN] Run <id> <slug> timed out after <s>s; process killed` on timeout
|
||||
|
||||
The GUI's bottom panel auto-refreshes the same log file every 3 seconds. Tick the **Auto-scroll** checkbox to keep it pinned to the latest line.
|
||||
|
||||
## Common issues
|
||||
|
||||
### "Disconnected: Access to the path is denied" right after install
|
||||
|
||||
You launched the GUI without elevation. The admin pipe ACL is `SYSTEM` + `Administrators`-full-control; UAC token splitting denies the standard token.
|
||||
|
||||
**Fix in v0.1.1+**: nothing — the GUI's manifest is `requireAdministrator` and Start Menu / shortcut launches auto-elevate.
|
||||
|
||||
**Fix in v0.1.0**: right-click the Start Menu shortcut → **Run as administrator**, or upgrade.
|
||||
|
||||
### "Connection refused" hitting the hook URL
|
||||
|
||||
Three possibilities, in order of probability:
|
||||
|
||||
1. **Service stopped.** `Get-Service WebhookServer` and `Start-Service WebhookServer` if needed.
|
||||
2. **Wrong port.** Default is 8080. Check **Server → Settings → HTTP port** in the GUI, or `netstat -an | findstr :8080`.
|
||||
3. **Bound to a specific NIC and you're calling on another.** Check **Server → Settings → Listen on**. If "Listen on all interfaces" is unchecked and you only ticked LAN IPs, calls to `localhost` may fail. Tick `127.0.0.1` too.
|
||||
|
||||
### Hook works from `localhost` but not from another machine on the LAN
|
||||
|
||||
Windows Firewall. The installer doesn't add a firewall rule (intentional — you should choose your scope). Add one:
|
||||
|
||||
```powershell
|
||||
# from elevated PowerShell on the webhook host
|
||||
New-NetFirewallRule -DisplayName "Webhook Server HTTP 8080" -Direction Inbound `
|
||||
-Action Allow -Protocol TCP -LocalPort 8080 -Profile Domain,Private
|
||||
```
|
||||
|
||||
Use `-Profile Public` only if you really mean it. Better: front the server with a reverse proxy and don't expose 8080 directly.
|
||||
|
||||
### `[WRN] Run … failed to launch: launch error: An error occurred trying to start process 'X'. Access is denied.`
|
||||
|
||||
Likely **SpecificUser mode + `psi.UserName`** failure. Should be impossible in v0.1.1+ (we use `LogonUser` + `CreateProcessAsUser` directly). If you see this on v0.1.1, double-check the version: `Get-Item "C:\Program Files\WebhookServer\WebhookServer.Service.exe" | % VersionInfo`.
|
||||
|
||||
### `[WRN] Run … failed to launch: LogonUser (DOMAIN\user) failed`
|
||||
|
||||
The credentials don't authenticate. Common causes:
|
||||
|
||||
- Typo in the password (paste it back into the GUI to verify; the field is plaintext for an admin user)
|
||||
- Account locked / disabled / expired
|
||||
- The account is denied the right logon types — check `secpol.msc` → Local Policies → User Rights Assignment → "Deny logon as a batch job" / "Deny logon locally"
|
||||
- For domain accounts: the host can't reach a DC
|
||||
|
||||
### `non-zero exit=-1073741502` (`0xC0000142` STATUS_DLL_INIT_FAILED)
|
||||
|
||||
The new process couldn't initialize. With **InteractiveUser** mode this means we tried to open `winsta0\default` and the user's session token doesn't have access (e.g., no one's logged in). With **SpecificUser** this should not occur in v0.1.1+ — we deliberately don't set lpDesktop for that mode.
|
||||
|
||||
### Hook returns 502 with empty stdout/stderr
|
||||
|
||||
The script's exit was non-zero but it didn't print anything. PowerShell's `$ErrorActionPreference = 'Stop'` is your friend — turn it on at the top of the script and any cmdlet failure becomes terminating with a clear message in stderr.
|
||||
|
||||
### "ServiceState: ListenerSettingsChanged" → service restart
|
||||
|
||||
After saving Server Settings with a port or HTTPS change, the service stops itself so the SCM restarts it on the new bindings. The GUI briefly shows "Disconnected" then reconnects. If it doesn't reconnect within ~10 seconds:
|
||||
|
||||
```powershell
|
||||
Get-Service WebhookServer | Format-List Status, StartType
|
||||
```
|
||||
|
||||
If the service is in `Stopped`, the SCM didn't restart it (failure-recovery only kicks in on *abnormal* termination, and a clean stop doesn't qualify). Manual:
|
||||
|
||||
```powershell
|
||||
Start-Service WebhookServer
|
||||
```
|
||||
|
||||
### GUI editor changes don't seem to take effect
|
||||
|
||||
After saving an endpoint, the service loads the new config in memory immediately — no restart needed. If a hook is mid-run when you save, that run finishes against the OLD config; the new config applies to subsequent runs.
|
||||
|
||||
If the GUI's grid still shows old values, hit any other endpoint or wait for the 3-second poll to refresh the display.
|
||||
|
||||
### Tray icon doesn't appear
|
||||
|
||||
Check whether the GUI is running: `Get-Process WebhookServer.Gui`. If not, the tray icon doesn't exist (it's part of the GUI process). To have a persistent tray independent of the main window, leave the GUI running and minimize it — it'll hide-to-tray rather than truly close.
|
||||
|
||||
To run the GUI minimized at login: create a Windows shortcut to `WebhookServer.Gui.exe`, set "Run" to "Minimized" in the shortcut properties, and put it in your user's Startup folder (`shell:startup`). The auto-elevate manifest still takes effect.
|
||||
|
||||
## Getting useful logs from a script
|
||||
|
||||
Inside your hook scripts, write to stderr for diagnostic info — Webhook Server logs stderr separately from stdout, and stderr is preserved even on success:
|
||||
|
||||
```powershell
|
||||
[Console]::Error.WriteLine("processing item $i of $total")
|
||||
```
|
||||
|
||||
Or use `Write-Error` which produces non-fatal errors:
|
||||
|
||||
```powershell
|
||||
Write-Error "skipping bogus input" # stderr but doesn't terminate
|
||||
```
|
||||
|
||||
The full stderr appears in the log line for the run, plus in the response body for sync calls.
|
||||
|
||||
## Asking for help
|
||||
|
||||
If you're stuck, file an issue at:
|
||||
|
||||
> https://github.com/recklessop/webhook-server/issues
|
||||
|
||||
Include:
|
||||
|
||||
- Webhook Server version (Help → About, or the file version of the `.exe`)
|
||||
- Windows version (`winver`)
|
||||
- The slug + relevant bits of the endpoint config (NOT the secrets)
|
||||
- The log lines for the failing run (search for the runId)
|
||||
- What you expected vs. what happened
|
||||
@@ -0,0 +1,90 @@
|
||||
# Uninstalling
|
||||
|
||||
## TL;DR
|
||||
|
||||
**Settings → Apps & features → Webhook Server → Uninstall.** Or right-click the **Uninstall Webhook Server** Start Menu shortcut.
|
||||
|
||||
Your endpoints, secrets, and logs in `C:\ProgramData\WebhookServer\` are preserved by default. To wipe those too, see [Below](#wiping-config-and-logs-too).
|
||||
|
||||
## What the uninstaller does
|
||||
|
||||
In order:
|
||||
|
||||
1. **Stops the service** (`net stop WebhookServer`).
|
||||
2. **Removes the service** registration via `uninstall-service.ps1` (which calls `sc.exe delete WebhookServer`).
|
||||
3. **Deletes** `C:\Program Files\WebhookServer\`.
|
||||
4. **Removes** the Start Menu and (if created) Desktop shortcuts.
|
||||
5. **Removes** the Programs and Features entry.
|
||||
|
||||
What it **does not** touch:
|
||||
|
||||
- `C:\ProgramData\WebhookServer\` (config, secrets, log files, auto-snapshots)
|
||||
- Any cert in your local cert store you bound HTTPS to
|
||||
- Domain accounts / gMSAs the service ran under
|
||||
- Endpoints' deployed scripts, if you stored them outside the install dir
|
||||
|
||||
## Wiping config and logs too
|
||||
|
||||
After running the uninstaller, also remove the data root:
|
||||
|
||||
```powershell
|
||||
# from elevated PowerShell
|
||||
Remove-Item -Recurse -Force "$env:ProgramData\WebhookServer"
|
||||
```
|
||||
|
||||
This deletes:
|
||||
|
||||
- `config.json` (with all your endpoints, encrypted secrets, settings)
|
||||
- `backups\` (all auto-snapshots — you can't restore from these once gone)
|
||||
- `logs\` (history of every webhook hit)
|
||||
|
||||
There's no recovery from this. If you might want to reinstall later with the same configuration, copy `config.json` to a safe location first. Note that **secrets in the saved config can only be decrypted on the same machine** (DPAPI LocalMachine scope) — you can move the file but the bearer/HMAC/RunAs passwords inside become unrecoverable on a different host.
|
||||
|
||||
## Silent uninstall
|
||||
|
||||
The Programs and Features uninstaller is `unins000.exe` in the install directory:
|
||||
|
||||
```powershell
|
||||
# from elevated PowerShell
|
||||
& "C:\Program Files\WebhookServer\unins000.exe" /VERYSILENT /SUPPRESSMSGBOXES /NORESTART
|
||||
```
|
||||
|
||||
Same set of preserved/removed paths as the interactive flow.
|
||||
|
||||
## Removing only the service, keeping the binaries
|
||||
|
||||
If you want to keep the GUI installed but stop running the service (rare, but useful if you're testing):
|
||||
|
||||
```powershell
|
||||
# from elevated PowerShell
|
||||
sc.exe stop WebhookServer
|
||||
sc.exe delete WebhookServer
|
||||
```
|
||||
|
||||
The GUI will show **Disconnected** since there's no service to talk to. Re-create the service later by running `install-service.ps1`:
|
||||
|
||||
```powershell
|
||||
& "C:\Program Files\WebhookServer\scripts\install-service.ps1" `
|
||||
-BinaryPath "C:\Program Files\WebhookServer\WebhookServer.Service.exe"
|
||||
```
|
||||
|
||||
## Edge cases
|
||||
|
||||
### "The service cannot be stopped because it has not been started."
|
||||
|
||||
Harmless. The uninstaller proceeds regardless.
|
||||
|
||||
### "Cannot delete: file in use"
|
||||
|
||||
A GUI window or other process is holding files in `C:\Program Files\WebhookServer\` open. Close everything and re-run the uninstaller. If that fails, reboot and re-run.
|
||||
|
||||
### Programs and Features entry remains after files are gone
|
||||
|
||||
If you deleted `C:\Program Files\WebhookServer\` manually before running the uninstaller, `unins000.exe` is gone too and Programs and Features can't run it. Remove the orphan entry by deleting its registry key:
|
||||
|
||||
```powershell
|
||||
# from elevated PowerShell - dry run to confirm the key exists
|
||||
Get-Item 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\{6E3B3C1A-9C20-4F50-B6A8-2B6D6D7E2F11}_is1' -ErrorAction SilentlyContinue
|
||||
# if it shows up, delete it:
|
||||
Remove-Item 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\{6E3B3C1A-9C20-4F50-B6A8-2B6D6D7E2F11}_is1' -Recurse
|
||||
```
|
||||
@@ -0,0 +1,76 @@
|
||||
# Upgrading
|
||||
|
||||
## TL;DR
|
||||
|
||||
Download the new installer from [Releases](https://github.com/recklessop/webhook-server/releases/latest) and run it. That's it. Your config, endpoints, secrets, and logs are preserved.
|
||||
|
||||
## What the upgrade does
|
||||
|
||||
The Inno Setup installer detects an existing install and runs through these steps automatically:
|
||||
|
||||
1. **`net stop WebhookServer`** — synchronously stops the running service so its binaries are unlocked. Blocks until the SCM reports the service is actually stopped.
|
||||
2. **`taskkill /f /im WebhookServer.Gui.exe`** — closes the GUI if you left it running. Same for any orphan `WebhookServer.Service.exe` from a `deploy.ps1` dev install.
|
||||
3. **Copies** the new binaries into `C:\Program Files\WebhookServer\`. Files marked `ignoreversion` so newer files always overwrite older ones, even if version metadata happens to match.
|
||||
4. **Re-registers** the service via `install-service.ps1`, which detects the existing `WebhookServer` service via `Get-Service` and takes the **update** branch (changes the binary path) rather than re-creating it. Your service account choice is preserved.
|
||||
5. **Starts the service**. The GUI launches if you left the post-install checkbox ticked.
|
||||
|
||||
Total downtime for the service: 2–10 seconds depending on disk speed and how long the service takes to flush its log buffer.
|
||||
|
||||
## What's preserved
|
||||
|
||||
- `C:\ProgramData\WebhookServer\config.json` — the installer never touches this directory
|
||||
- All endpoints, secrets, callback URLs, allowlists
|
||||
- Bind addresses, display host, HTTPS binding settings
|
||||
- Auto-snapshots in `C:\ProgramData\WebhookServer\backups\`
|
||||
- Log files in `C:\ProgramData\WebhookServer\logs\`
|
||||
- The Windows Service identity (LocalSystem, gMSA, domain user — whatever you configured)
|
||||
|
||||
## What gets replaced
|
||||
|
||||
- Everything in `C:\Program Files\WebhookServer\` — the .exe files, .dll files, the icon, `install-service.ps1`, `uninstall-service.ps1`, the bundled `README.md`, the `docs/` folder
|
||||
|
||||
## Silent upgrades (Group Policy / SCCM / Intune / Ansible)
|
||||
|
||||
Same as the silent install:
|
||||
|
||||
```powershell
|
||||
WebhookServer-Setup-X.Y.Z.exe /VERYSILENT /SUPPRESSMSGBOXES /NORESTART
|
||||
```
|
||||
|
||||
The pre-install `net stop` step still fires; downtime is unchanged.
|
||||
|
||||
## Rolling back to a previous version
|
||||
|
||||
The installer doesn't support side-by-side versions or downgrade detection. To roll back:
|
||||
|
||||
1. Uninstall the current version (Settings → Apps, or `Start Menu → Webhook Server → Uninstall`). This stops + removes the service. Your config in `C:\ProgramData\WebhookServer\` is preserved.
|
||||
2. Run the older installer.
|
||||
|
||||
If a config field changed semantics between versions and you ran on the new version first, the **Config Checkpoints** menu (File → Config Checkpoints) lists snapshots taken before each save. The auto-snapshot from immediately before the upgrade is the closest you'll have to your pre-upgrade config.
|
||||
|
||||
## Edge cases
|
||||
|
||||
### "Setup cannot continue. Please close the following applications: WebhookServer.Gui.exe"
|
||||
|
||||
The taskkill step normally handles this, but if you're running an unusually slow process or if the GUI was elevated by a different user, you may see this. Close the GUI manually and click Retry.
|
||||
|
||||
### Service stays in a "Stopping" state forever
|
||||
|
||||
`net stop` waits up to 30 seconds for the service to stop. If a hook script hung (e.g. interactive prompt) and the service can't kill it cleanly, the SCM gives up and the install continues, but the service may end up in a bad state. Recovery:
|
||||
|
||||
```powershell
|
||||
# from elevated PowerShell
|
||||
Stop-Service WebhookServer -Force
|
||||
# if that fails:
|
||||
Get-WmiObject Win32_Service -Filter "Name='WebhookServer'" | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }
|
||||
```
|
||||
|
||||
…then re-run the installer.
|
||||
|
||||
### Upgrade from a `deploy.ps1` dev install to an installer-managed install
|
||||
|
||||
The first time you run the installer on a machine that previously used `deploy.ps1`, the installer thinks it's doing a fresh install (no `Programs and Features` registry entry). It still detects the existing service and updates it cleanly, so the only visible difference is that **a Programs and Features entry now exists** for "Webhook Server" with `Justin Paul` as publisher. Future upgrades take the proper upgrade path.
|
||||
|
||||
### `deploy.ps1` after an installer-managed install
|
||||
|
||||
`deploy.ps1` is the dev workflow. It publishes from source and copies binaries to the same install location. Running it on top of an installer-managed install will overwrite the binaries but won't deregister the installer. If you then uninstall via Programs and Features, the uninstaller may leave files behind that `deploy.ps1` introduced. Pick one workflow and stick with it.
|
||||
@@ -0,0 +1,124 @@
|
||||
; Inno Setup script for Webhook Server.
|
||||
;
|
||||
; Build: iscc /DAppVersion=0.1.0 webhook-server.iss
|
||||
; Output: ..\dist\WebhookServer-Setup-{AppVersion}.exe
|
||||
;
|
||||
; The installer copies published binaries to {pf}\WebhookServer, installs the
|
||||
; Windows Service via install-service.ps1 post-install, and removes the service
|
||||
; via uninstall-service.ps1 pre-uninstall. Start Menu gets a single GUI shortcut.
|
||||
|
||||
#ifndef AppVersion
|
||||
#define AppVersion "0.1.0"
|
||||
#endif
|
||||
|
||||
#define AppName "Webhook Server"
|
||||
#define AppPublisher "Justin Paul"
|
||||
#define AppURL "https://jpaul.me"
|
||||
#define AppExeName "WebhookServer.Gui.exe"
|
||||
#define ServiceExeName "WebhookServer.Service.exe"
|
||||
#define ServiceName "WebhookServer"
|
||||
#define RepoRoot "..\"
|
||||
|
||||
[Setup]
|
||||
AppId={{6E3B3C1A-9C20-4F50-B6A8-2B6D6D7E2F11}
|
||||
AppName={#AppName}
|
||||
AppVersion={#AppVersion}
|
||||
AppPublisher={#AppPublisher}
|
||||
AppPublisherURL={#AppURL}
|
||||
AppSupportURL=https://github.com/recklessop/webhook-server
|
||||
AppUpdatesURL=https://github.com/recklessop/webhook-server/releases
|
||||
DefaultDirName={autopf}\WebhookServer
|
||||
DefaultGroupName={#AppName}
|
||||
DisableProgramGroupPage=yes
|
||||
OutputBaseFilename=WebhookServer-Setup-{#AppVersion}
|
||||
OutputDir={#RepoRoot}dist
|
||||
SetupIconFile={#RepoRoot}resources\webhook-server.ico
|
||||
UninstallDisplayIcon={app}\{#AppExeName}
|
||||
PrivilegesRequired=admin
|
||||
ArchitecturesAllowed=x64compatible
|
||||
ArchitecturesInstallIn64BitMode=x64compatible
|
||||
Compression=lzma2/max
|
||||
SolidCompression=yes
|
||||
WizardStyle=modern
|
||||
VersionInfoVersion={#AppVersion}.0
|
||||
VersionInfoCompany={#AppPublisher}
|
||||
VersionInfoProductName={#AppName}
|
||||
|
||||
[Languages]
|
||||
Name: "english"; MessagesFile: "compiler:Default.isl"
|
||||
|
||||
[Tasks]
|
||||
Name: "desktopicon"; Description: "Create a &desktop shortcut"; GroupDescription: "Additional shortcuts:"; Flags: unchecked
|
||||
|
||||
[Files]
|
||||
Source: "{#RepoRoot}publish\service\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
|
||||
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
|
||||
|
||||
[Icons]
|
||||
Name: "{group}\{#AppName}"; Filename: "{app}\{#AppExeName}"; IconFilename: "{app}\webhook-server.ico"
|
||||
Name: "{group}\Uninstall {#AppName}"; Filename: "{uninstallexe}"
|
||||
Name: "{commondesktop}\{#AppName}"; Filename: "{app}\{#AppExeName}"; IconFilename: "{app}\webhook-server.ico"; Tasks: desktopicon
|
||||
|
||||
[Run]
|
||||
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
|
||||
|
||||
[UninstallRun]
|
||||
Filename: "powershell.exe"; \
|
||||
Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{app}\scripts\uninstall-service.ps1"""; \
|
||||
Flags: runhidden; \
|
||||
RunOnceId: "RemoveWebhookService"
|
||||
|
||||
[Code]
|
||||
function ServiceExists(): Boolean;
|
||||
var
|
||||
ResultCode: Integer;
|
||||
begin
|
||||
// sc.exe query returns 0 when the service exists, 1060 when it does not.
|
||||
Exec(ExpandConstant('{sys}\sc.exe'), 'query WebhookServer', '', SW_HIDE,
|
||||
ewWaitUntilTerminated, ResultCode);
|
||||
Result := (ResultCode = 0);
|
||||
end;
|
||||
|
||||
function PrepareToInstall(var NeedsRestart: Boolean): String;
|
||||
var
|
||||
ResultCode: Integer;
|
||||
begin
|
||||
Result := '';
|
||||
|
||||
// 1. If the service exists, stop it so its binaries are unlocked before file
|
||||
// copy. net stop is synchronous (blocks until the service is actually
|
||||
// stopped), unlike sc stop which is fire-and-forget. Non-zero exit -
|
||||
// already stopped, missing, dependency error - we ignore; the file copy
|
||||
// will fail loudly if the binaries are still locked.
|
||||
if ServiceExists() then
|
||||
begin
|
||||
WizardForm.PreparingLabel.Caption := 'Stopping the WebhookServer service...';
|
||||
Exec(ExpandConstant('{sys}\net.exe'), 'stop WebhookServer', '', SW_HIDE,
|
||||
ewWaitUntilTerminated, ResultCode);
|
||||
end;
|
||||
|
||||
// 2. Kill any running GUI / tray instances so their binaries are unlocked too.
|
||||
// /f forces termination, /im matches by image name, "*" wildcard would be
|
||||
// risky so we name them explicitly.
|
||||
Exec(ExpandConstant('{sys}\taskkill.exe'), '/f /im WebhookServer.Gui.exe',
|
||||
'', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||
Exec(ExpandConstant('{sys}\taskkill.exe'), '/f /im WebhookServer.Service.exe',
|
||||
'', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||
end;
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1,203 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
End-to-end installer build: publish service + GUI, then run Inno Setup
|
||||
to produce dist/WebhookServer-Setup-{version}.exe.
|
||||
|
||||
.DESCRIPTION
|
||||
Reads the version from Directory.Build.props. Requires Inno Setup 6 (ISCC.exe)
|
||||
on PATH or in the standard install location. CI runs this same script after
|
||||
setup-dotnet + winget install Inno Setup.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$Configuration = 'Release',
|
||||
[string]$VersionOverride
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$repoRoot = Split-Path -Parent $PSScriptRoot
|
||||
|
||||
function Get-RepoVersion {
|
||||
$propsPath = Join-Path $repoRoot 'Directory.Build.props'
|
||||
[xml]$props = Get-Content $propsPath
|
||||
return $props.Project.PropertyGroup.Version
|
||||
}
|
||||
|
||||
function Find-InnoCompiler {
|
||||
$candidates = @(
|
||||
'ISCC.exe', # on PATH
|
||||
'C:\Program Files (x86)\Inno Setup 6\ISCC.exe',
|
||||
'C:\Program Files\Inno Setup 6\ISCC.exe'
|
||||
)
|
||||
foreach ($c in $candidates) {
|
||||
$cmd = Get-Command $c -ErrorAction SilentlyContinue
|
||||
if ($cmd) { return $cmd.Path }
|
||||
if (Test-Path $c) { return $c }
|
||||
}
|
||||
throw "Inno Setup compiler not found. Install with: winget install JRSoftware.InnoSetup"
|
||||
}
|
||||
|
||||
$version = if ($VersionOverride) { $VersionOverride } else { Get-RepoVersion }
|
||||
Write-Host "Building Webhook Server installer v$version" -ForegroundColor Cyan
|
||||
|
||||
# 1. Publish both projects.
|
||||
$publishSvc = Join-Path $repoRoot 'publish\service'
|
||||
$publishGui = Join-Path $repoRoot 'publish\gui'
|
||||
Remove-Item -Recurse -Force $publishSvc, $publishGui -ErrorAction SilentlyContinue
|
||||
|
||||
& dotnet publish (Join-Path $repoRoot 'src\WebhookServer.Service\WebhookServer.Service.csproj') `
|
||||
-c $Configuration -r win-x64 --self-contained false -o $publishSvc | Out-Host
|
||||
if ($LASTEXITCODE -ne 0) { throw 'service publish failed' }
|
||||
|
||||
& dotnet publish (Join-Path $repoRoot 'src\WebhookServer.Gui\WebhookServer.Gui.csproj') `
|
||||
-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.
|
||||
$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
|
||||
|
||||
# Extra pre-flight: confirm the specific files our .iss references that a
|
||||
# trivial test .iss wouldn't (icon, README, scripts) actually exist relative
|
||||
# to the .iss directory the way ISCC will resolve them (RepoRoot = ..\).
|
||||
Write-Host "--- pre-flight: paths the .iss references via {#RepoRoot} ---" -ForegroundColor Cyan
|
||||
$issRefs = @(
|
||||
'resources\webhook-server.ico',
|
||||
'README.md',
|
||||
'scripts\install-service.ps1',
|
||||
'scripts\uninstall-service.ps1',
|
||||
'publish\service',
|
||||
'publish\gui',
|
||||
'docs',
|
||||
'scripts\examples'
|
||||
)
|
||||
foreach ($ref in $issRefs) {
|
||||
$abs = Join-Path $repoRoot $ref
|
||||
$exists = Test-Path $abs
|
||||
Write-Host (" {0,-40} exists={1} ({2})" -f $ref, $exists, $abs)
|
||||
}
|
||||
Write-Host ""
|
||||
|
||||
Write-Host "--- runtime context ---" -ForegroundColor Cyan
|
||||
Write-Host " identity: $([Security.Principal.WindowsIdentity]::GetCurrent().Name)"
|
||||
Write-Host " USERPROFILE: $env:USERPROFILE"
|
||||
Write-Host " APPDATA: $env:APPDATA"
|
||||
Write-Host " LOCALAPPDATA: $env:LOCALAPPDATA"
|
||||
Write-Host " TEMP: $env:TEMP"
|
||||
$isccDir = Split-Path $iscc -Parent
|
||||
Write-Host " ISCC dir: $isccDir"
|
||||
foreach ($f in @('ISCC.exe','ISCmplr.dll','ISPP.dll','Default.isl','Compil32.exe')) {
|
||||
$p = Join-Path $isccDir $f
|
||||
Write-Host (" {0,-15} exists={1}" -f $f, (Test-Path $p))
|
||||
}
|
||||
Write-Host ""
|
||||
|
||||
Write-Host " PS location (pre): $((Get-Location).Path)"
|
||||
Write-Host " .NET cwd (pre): $([System.IO.Directory]::GetCurrentDirectory())"
|
||||
|
||||
Push-Location $issDir
|
||||
$savedDotNetCwd = [System.IO.Directory]::GetCurrentDirectory()
|
||||
[System.IO.Directory]::SetCurrentDirectory($issDir)
|
||||
try {
|
||||
Write-Host " PS location (post): $((Get-Location).Path)"
|
||||
Write-Host " .NET cwd (post): $([System.IO.Directory]::GetCurrentDirectory())"
|
||||
|
||||
# Sanity: compile a minimal .iss right next to ours BEFORE attempting the
|
||||
# real one. Minimal has no #defines, no [Code], no [Files], no compression
|
||||
# tweak - just the absolute floor of what ISCC will accept. If THIS fails
|
||||
# under the same SYSTEM context with the same identical exit/error, the
|
||||
# problem is environmental, not in our .iss content.
|
||||
$minIss = Join-Path $issDir "min-test.iss"
|
||||
@"
|
||||
[Setup]
|
||||
AppName=MinTest
|
||||
AppVersion=1.0
|
||||
AppId={{12345678-1234-1234-1234-123456789ABC}
|
||||
DefaultDirName={pf}\MinTest
|
||||
CreateAppDir=no
|
||||
Uninstallable=no
|
||||
OutputBaseFilename=mintest
|
||||
OutputDir=$dist
|
||||
"@ | Set-Content -Path $minIss -Encoding ascii
|
||||
Write-Host ""
|
||||
Write-Host "--- bisect step 1: minimal .iss ---" -ForegroundColor Cyan
|
||||
& $iscc (Split-Path $minIss -Leaf) *>&1 | ForEach-Object { Write-Host " $_" }
|
||||
$minExit = $LASTEXITCODE
|
||||
Write-Host " minimal exit: $minExit"
|
||||
Remove-Item $minIss -ErrorAction SilentlyContinue
|
||||
Write-Host ""
|
||||
|
||||
# Bake the version into a temp .iss and override OutputDir to an absolute
|
||||
# path so nothing in the build depends on cwd resolution.
|
||||
$tempIss = Join-Path $issDir "webhook-server.gen.iss"
|
||||
$issBody = Get-Content $issName -Raw
|
||||
$pattern = '(?s)#ifndef AppVersion\s+#define AppVersion "[^"]*"\s+#endif'
|
||||
if ($issBody -notmatch $pattern) { throw "Could not find #ifndef AppVersion block in $issName" }
|
||||
$issBody = $issBody -replace $pattern, "#define AppVersion `"$version`""
|
||||
Set-Content -Path $tempIss -Value $issBody -Encoding ascii
|
||||
Write-Host " using $tempIss"
|
||||
|
||||
# Capture stdout+stderr together so any error line ISCC emits is visible
|
||||
# in the runner log even if the runner's console capture drops one stream.
|
||||
# /O<absolute> overrides OutputDir so ..\dist isn't resolved relative to
|
||||
# whatever cwd ISCC actually inherits.
|
||||
$logPath = Join-Path $env:TEMP "iscc-$version.log"
|
||||
& $iscc "/O$dist" (Split-Path $tempIss -Leaf) *>&1 | Tee-Object -FilePath $logPath | ForEach-Object { Write-Host $_ }
|
||||
$exit = $LASTEXITCODE
|
||||
Write-Host " ISCC exit code: $exit"
|
||||
Write-Host " ISCC log path: $logPath"
|
||||
if (Test-Path $logPath) {
|
||||
Write-Host " --- iscc log file contents ---"
|
||||
Get-Content $logPath | ForEach-Object { Write-Host " $_" }
|
||||
Write-Host " --- end iscc log ---"
|
||||
}
|
||||
Remove-Item $tempIss -ErrorAction SilentlyContinue
|
||||
} finally {
|
||||
[System.IO.Directory]::SetCurrentDirectory($savedDotNetCwd)
|
||||
Pop-Location
|
||||
}
|
||||
if ($exit -ne 0) { throw "Inno Setup compile failed (exit $exit)" }
|
||||
|
||||
$out = Get-Item (Join-Path $dist "WebhookServer-Setup-$version.exe")
|
||||
Write-Host ""
|
||||
Write-Host ("Built: {0} ({1:n0} bytes)" -f $out.FullName, $out.Length) -ForegroundColor Green
|
||||
@@ -0,0 +1,102 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Builds, publishes, copies, installs, and starts WebhookServer as a Windows Service
|
||||
running under LocalSystem.
|
||||
|
||||
.DESCRIPTION
|
||||
Idempotent - safe to re-run after code changes. Stops the service first so binaries
|
||||
aren't locked, copies the latest published output to InstallRoot, then re-creates or
|
||||
re-configures the service and starts it.
|
||||
|
||||
Must be run from an elevated PowerShell.
|
||||
|
||||
.PARAMETER InstallRoot
|
||||
Where the binaries get copied. Defaults to "C:\Program Files\WebhookServer".
|
||||
|
||||
.PARAMETER ServiceAccount
|
||||
Service identity. Defaults to LocalSystem. For AD-aware hooks pass a domain user
|
||||
or gMSA - see the Service account section in README.md.
|
||||
|
||||
.PARAMETER SkipBuild
|
||||
Skip the dotnet publish step (use the existing publish\ output as-is).
|
||||
|
||||
.EXAMPLE
|
||||
# First-time install (and after any code change)
|
||||
.\deploy.ps1
|
||||
|
||||
.EXAMPLE
|
||||
# Run service under a gMSA
|
||||
.\deploy.ps1 -ServiceAccount 'CONTOSO\svc-webhookserver$'
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$InstallRoot = 'C:\Program Files\WebhookServer',
|
||||
[string]$ServiceName = 'WebhookServer',
|
||||
[string]$ServiceAccount = 'LocalSystem',
|
||||
[string]$Password,
|
||||
[switch]$SkipBuild
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$principal = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent())
|
||||
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
|
||||
throw 'deploy.ps1 must be run from an elevated PowerShell.'
|
||||
}
|
||||
|
||||
$repoRoot = Split-Path -Parent $PSScriptRoot
|
||||
$publishSvc = Join-Path $repoRoot 'publish\service'
|
||||
$publishGui = Join-Path $repoRoot 'publish\gui'
|
||||
|
||||
# 1. Stop the service if it's already installed so its binaries aren't locked.
|
||||
$svc = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
|
||||
if ($svc -and $svc.Status -ne 'Stopped') {
|
||||
Write-Host "Stopping existing service '$ServiceName'..."
|
||||
Stop-Service -Name $ServiceName -Force
|
||||
$svc.WaitForStatus('Stopped', '00:00:30')
|
||||
}
|
||||
|
||||
# Belt-and-braces: kill any orphan dev-launch processes still holding the binaries.
|
||||
Get-Process -Name 'WebhookServer.Service','WebhookServer.Gui' -ErrorAction SilentlyContinue |
|
||||
ForEach-Object { try { $_ | Stop-Process -Force } catch { } }
|
||||
|
||||
# 2. Publish (unless told to skip).
|
||||
if (-not $SkipBuild) {
|
||||
Write-Host 'Publishing service + GUI...'
|
||||
& dotnet publish (Join-Path $repoRoot 'src\WebhookServer.Service\WebhookServer.Service.csproj') `
|
||||
-c Release -r win-x64 --self-contained false -o $publishSvc | Out-Host
|
||||
if ($LASTEXITCODE -ne 0) { throw 'service publish failed' }
|
||||
|
||||
& dotnet publish (Join-Path $repoRoot 'src\WebhookServer.Gui\WebhookServer.Gui.csproj') `
|
||||
-c Release -r win-x64 --self-contained false -o $publishGui | Out-Host
|
||||
if ($LASTEXITCODE -ne 0) { throw 'GUI publish failed' }
|
||||
}
|
||||
|
||||
# 3. Copy binaries into InstallRoot.
|
||||
Write-Host "Copying binaries to $InstallRoot..."
|
||||
New-Item -ItemType Directory -Path $InstallRoot -Force | Out-Null
|
||||
Copy-Item -Path (Join-Path $publishSvc '*') -Destination $InstallRoot -Recurse -Force
|
||||
Copy-Item -Path (Join-Path $publishGui '*') -Destination $InstallRoot -Recurse -Force
|
||||
|
||||
$serviceExe = Join-Path $InstallRoot 'WebhookServer.Service.exe'
|
||||
$guiExe = Join-Path $InstallRoot 'WebhookServer.Gui.exe'
|
||||
|
||||
# 4. Create or update the Windows Service via install-service.ps1.
|
||||
$installArgs = @{
|
||||
BinaryPath = $serviceExe
|
||||
ServiceName = $ServiceName
|
||||
ServiceAccount = $ServiceAccount
|
||||
}
|
||||
if ($PSBoundParameters.ContainsKey('Password')) { $installArgs.Password = $Password }
|
||||
& (Join-Path $PSScriptRoot 'install-service.ps1') @installArgs
|
||||
|
||||
# 5. Show how to launch the GUI.
|
||||
Write-Host ''
|
||||
Write-Host '=== Deployed ===' -ForegroundColor Green
|
||||
Write-Host " Service exe : $serviceExe"
|
||||
Write-Host " GUI exe : $guiExe"
|
||||
Write-Host " Config : $env:ProgramData\WebhookServer\config.json"
|
||||
Write-Host " Logs : $env:ProgramData\WebhookServer\logs"
|
||||
Write-Host ''
|
||||
Write-Host 'Launch the GUI (must stay elevated to talk to the admin pipe):'
|
||||
Write-Host " Start-Process -FilePath '$guiExe' -Verb RunAs"
|
||||
@@ -0,0 +1,83 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Dev launcher: starts the service in one window and the GUI in another, both
|
||||
pointing at an isolated data root so production %ProgramData% is not touched.
|
||||
|
||||
.DESCRIPTION
|
||||
MUST be run from an elevated PowerShell - the admin pipe is ACL'd to SYSTEM
|
||||
and the Administrators group, and a non-elevated process cannot connect.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$DataRoot = (Join-Path $env:TEMP 'webhook-dev'),
|
||||
[int]$HttpPort = 18080
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$servicePath = Join-Path $root 'publish\service\WebhookServer.Service.exe'
|
||||
$guiPath = Join-Path $root 'publish\gui\WebhookServer.Gui.exe'
|
||||
|
||||
if (-not (Test-Path $servicePath)) { throw "Service not built. Run: dotnet publish src/WebhookServer.Service -c Release -o publish/service" }
|
||||
if (-not (Test-Path $guiPath)) { throw "GUI not built. Run: dotnet publish src/WebhookServer.Gui -c Release -o publish/gui" }
|
||||
|
||||
# Verify the current shell is elevated.
|
||||
$currentPrincipal = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent())
|
||||
if (-not $currentPrincipal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
|
||||
throw 'This script must be run from an elevated PowerShell so the GUI can connect to the SYSTEM/Admins-only admin pipe.'
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path $DataRoot -Force | Out-Null
|
||||
New-Item -ItemType Directory -Path (Join-Path $DataRoot 'logs') -Force | Out-Null
|
||||
|
||||
$cfgPath = Join-Path $DataRoot 'config.json'
|
||||
if (-not (Test-Path $cfgPath)) {
|
||||
$cfg = @"
|
||||
{
|
||||
"httpPort": $HttpPort,
|
||||
"trustedProxies": [],
|
||||
"logRetentionDays": 7,
|
||||
"endpoints": [
|
||||
{
|
||||
"id": "11111111-1111-1111-1111-111111111111",
|
||||
"slug": "ping",
|
||||
"description": "Trivial sync hook",
|
||||
"enabled": true,
|
||||
"allowedClients": [],
|
||||
"authMode": "none",
|
||||
"executorType": "windowsPowerShell",
|
||||
"inlineCommand": "Write-Output 'pong'",
|
||||
"executableArgs": [],
|
||||
"dataPassing": { "stdinJson": false, "envVars": false, "argTemplate": false },
|
||||
"responseMode": "sync",
|
||||
"timeoutSeconds": 30,
|
||||
"failOnNonZeroExit": true,
|
||||
"serialize": false
|
||||
}
|
||||
]
|
||||
}
|
||||
"@
|
||||
Set-Content -Path $cfgPath -Value $cfg -Encoding utf8
|
||||
}
|
||||
|
||||
Write-Host "Data root : $DataRoot"
|
||||
Write-Host "Config : $cfgPath"
|
||||
Write-Host "Service exe: $servicePath"
|
||||
Write-Host "GUI exe : $guiPath"
|
||||
Write-Host ""
|
||||
|
||||
$serviceArgs = @(
|
||||
'-NoExit', '-NoProfile', '-Command',
|
||||
"`$env:WEBHOOKSERVER_DATA = '$DataRoot'; & '$servicePath'"
|
||||
)
|
||||
Start-Process powershell -ArgumentList $serviceArgs -WindowStyle Normal
|
||||
|
||||
Start-Sleep -Seconds 2
|
||||
|
||||
# GUI inherits this shell's environment automatically.
|
||||
$env:WEBHOOKSERVER_DATA = $DataRoot
|
||||
Start-Process -FilePath $guiPath
|
||||
|
||||
Write-Host "Service window opened; GUI launched."
|
||||
Write-Host "Hit http://localhost:$HttpPort/healthz to confirm Kestrel is up."
|
||||
Write-Host "Logs: $(Join-Path $DataRoot 'logs')"
|
||||
@@ -0,0 +1,46 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Server-side receiver for the env-dump webhook. Reads the JSON body from
|
||||
stdin and writes it to a timestamped file on disk.
|
||||
|
||||
.DESCRIPTION
|
||||
Configure a webhook endpoint like this:
|
||||
Executable: powershell.exe (or pwsh.exe)
|
||||
Arguments: -NoProfile -ExecutionPolicy Bypass -File C:\path\to\save-env-vars.ps1
|
||||
Data passing: [x] Stdin JSON
|
||||
Run As: Service (or any account that can write to $OutDir)
|
||||
|
||||
Output goes to C:\ProgramData\WebhookServer\env-dumps\<host>-<utcstamp>.json
|
||||
by default; override with -OutDir.
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string] $OutDir = 'C:\ProgramData\WebhookServer\env-dumps'
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
if (-not (Test-Path $OutDir)) {
|
||||
New-Item -ItemType Directory -Path $OutDir -Force | Out-Null
|
||||
}
|
||||
|
||||
$body = [Console]::In.ReadToEnd()
|
||||
if ([string]::IsNullOrWhiteSpace($body)) {
|
||||
Write-Error 'Empty request body on stdin.'
|
||||
exit 2
|
||||
}
|
||||
|
||||
# Parse so we can pull the host name for the filename, and to fail fast on
|
||||
# malformed JSON before writing it.
|
||||
$parsed = $body | ConvertFrom-Json
|
||||
$hostName = if ($parsed.host) { $parsed.host } else { 'unknown' }
|
||||
$safeHost = ($hostName -replace '[^A-Za-z0-9_.-]', '_')
|
||||
$stamp = (Get-Date).ToUniversalTime().ToString('yyyyMMddTHHmmssZ')
|
||||
$path = Join-Path $OutDir "$safeHost-$stamp.json"
|
||||
|
||||
# Persist the original body verbatim - keeps key ordering and avoids any
|
||||
# round-trip surprises from ConvertTo-Json.
|
||||
Set-Content -Path $path -Value $body -Encoding utf8
|
||||
|
||||
Write-Host "Saved $($body.Length) bytes to $path"
|
||||
@@ -0,0 +1,68 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Collects env vars from PowerShell and bash, packages them into a single
|
||||
JSON object, and POSTs the result to a Webhook Server endpoint.
|
||||
|
||||
.DESCRIPTION
|
||||
Output JSON shape:
|
||||
{
|
||||
"host": "<computername>",
|
||||
"capturedAt":"2026-05-08T12:34:56Z",
|
||||
"pwsh": { "VAR": "value", ... },
|
||||
"bash": { "VAR": "value", ... }
|
||||
}
|
||||
|
||||
Pair this with `save-env-vars.ps1` on the server side - configure an
|
||||
endpoint with StdinJson enabled and that script as the executable.
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string] $WebhookUrl = 'http://localhost:8080/hook/env-dump',
|
||||
[string] $Bearer = '',
|
||||
[string] $BashExe = 'bash'
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# --- pwsh env vars --------------------------------------------------------
|
||||
$pwshVars = [ordered]@{}
|
||||
Get-ChildItem Env: | Sort-Object Name | ForEach-Object {
|
||||
$pwshVars[$_.Name] = $_.Value
|
||||
}
|
||||
|
||||
# --- bash env vars --------------------------------------------------------
|
||||
$bashVars = [ordered]@{}
|
||||
$bashCmd = Get-Command $BashExe -ErrorAction SilentlyContinue
|
||||
if ($null -ne $bashCmd) {
|
||||
# `env -0` separates entries with NUL so values containing newlines stay intact.
|
||||
$raw = & $bashCmd.Source -c 'env -0' 2>$null
|
||||
if ($LASTEXITCODE -eq 0 -and $raw) {
|
||||
foreach ($entry in ($raw -split "`0")) {
|
||||
if ([string]::IsNullOrEmpty($entry)) { continue }
|
||||
$eq = $entry.IndexOf('=')
|
||||
if ($eq -lt 1) { continue }
|
||||
$bashVars[$entry.Substring(0, $eq)] = $entry.Substring($eq + 1)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Write-Warning "bash not found on PATH (looked for '$BashExe'); 'bash' section will be empty."
|
||||
}
|
||||
|
||||
# --- assemble payload -----------------------------------------------------
|
||||
$payload = [ordered]@{
|
||||
host = $env:COMPUTERNAME
|
||||
capturedAt = (Get-Date).ToUniversalTime().ToString('o')
|
||||
pwsh = $pwshVars
|
||||
bash = $bashVars
|
||||
}
|
||||
|
||||
$json = $payload | ConvertTo-Json -Depth 5 -Compress
|
||||
|
||||
# --- POST -----------------------------------------------------------------
|
||||
$headers = @{ 'Content-Type' = 'application/json' }
|
||||
if ($Bearer) { $headers['Authorization'] = "Bearer $Bearer" }
|
||||
|
||||
Write-Host "POST $WebhookUrl ($($json.Length) bytes; pwsh=$($pwshVars.Count), bash=$($bashVars.Count))"
|
||||
$response = Invoke-RestMethod -Method Post -Uri $WebhookUrl -Headers $headers -Body $json
|
||||
$response | ConvertTo-Json -Depth 5
|
||||
@@ -0,0 +1,78 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Zerto post-failover script. Fires the on-prem Webhook Server which does
|
||||
the real work (DNS updates, service health checks, notifications).
|
||||
|
||||
.DESCRIPTION
|
||||
Designed to be dropped into a Zerto VPG's post-recovery script slot. The
|
||||
Zerto Virtual Manager's PowerShell runner has a limited module set and
|
||||
runs scripts synchronously, so this script:
|
||||
|
||||
- uses curl.exe (ships with Windows 10 1803+ / Server 2019+) instead
|
||||
of any module-dependent HTTP client;
|
||||
- calls an ASYNC webhook endpoint - the server returns 202 in
|
||||
milliseconds and runs the actual work in the background;
|
||||
- returns within seconds regardless of how long the post-failover
|
||||
actions take, so Zerto's failover sequence is never blocked.
|
||||
|
||||
Wire this into your VPG via the Zerto UI:
|
||||
VPG settings -> Recovery -> Scripts -> Post-Recovery Script
|
||||
Path: C:\path\to\zerto-post-failover.ps1
|
||||
Parameters: leave empty (we read from $env:ZertoVPGName)
|
||||
|
||||
.NOTES
|
||||
Configure $WebhookUrl and either:
|
||||
- paste the bearer token directly into $Bearer (simplest, but the
|
||||
token then lives in this file), or
|
||||
- point $BearerFile at a file readable only by the ZVM service
|
||||
account (better - same threat model as Zerto's own credential
|
||||
storage).
|
||||
#>
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# ----------------------------- CONFIGURE ---------------------------------
|
||||
$WebhookUrl = 'http://webhook.contoso.local:8080/hook/post-failover'
|
||||
$Bearer = '' # paste here, or use $BearerFile
|
||||
$BearerFile = 'C:\ProgramData\Zerto\webhook-token.txt' # one line: the token
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
if (-not $Bearer -and (Test-Path $BearerFile)) {
|
||||
$Bearer = (Get-Content -LiteralPath $BearerFile -TotalCount 1).Trim()
|
||||
}
|
||||
if (-not $Bearer) {
|
||||
throw "No bearer token. Set `$Bearer in this script or write the token to $BearerFile."
|
||||
}
|
||||
|
||||
# Compose the payload. Zerto exposes a few env vars; fall back gracefully.
|
||||
$payload = @{
|
||||
operation = 'failover'
|
||||
vpg = if ($env:ZertoVPGName) { $env:ZertoVPGName } else { 'unknown' }
|
||||
timestamp = (Get-Date).ToUniversalTime().ToString('o')
|
||||
} | ConvertTo-Json -Compress
|
||||
|
||||
# curl on Windows handles long / quoted JSON better via @file than via -d "...".
|
||||
$tempBody = Join-Path $env:TEMP ("zerto-webhook-{0}.json" -f ([guid]::NewGuid()))
|
||||
$payload | Out-File -FilePath $tempBody -Encoding utf8 -NoNewline
|
||||
|
||||
try {
|
||||
Write-Host "POST $WebhookUrl (vpg=$($env:ZertoVPGName))"
|
||||
& curl.exe `
|
||||
--silent --show-error --fail-with-body `
|
||||
--max-time 10 `
|
||||
-X POST `
|
||||
-H "Authorization: Bearer $Bearer" `
|
||||
-H "Content-Type: application/json" `
|
||||
-d "@$tempBody" `
|
||||
"$WebhookUrl"
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
# curl prints its own error to stderr; surface a non-zero exit so Zerto's
|
||||
# script log records the failure but we don't block the failover.
|
||||
Write-Warning "Webhook call failed with curl exit $LASTEXITCODE; continuing."
|
||||
} else {
|
||||
Write-Host "Webhook accepted (run id is in the response above)."
|
||||
}
|
||||
}
|
||||
finally {
|
||||
Remove-Item $tempBody -ErrorAction SilentlyContinue
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Webhook-server-side receiver: posts a Slack/Teams notification when a VPG
|
||||
fires its pre or post recovery script.
|
||||
|
||||
.DESCRIPTION
|
||||
Reads the JSON body from stdin (the payload sent by zerto-zvma-send.ps1),
|
||||
builds a phase-aware message, and posts it to an Incoming Webhook URL.
|
||||
|
||||
The message highlights:
|
||||
- VPG name + operation type (Test / Failover / Move / ...)
|
||||
- Whether ZertoForce was set (only relevant pre)
|
||||
- VM display names included in the run
|
||||
- Phase (pre vs post) so you can see the bracketing in chat
|
||||
|
||||
Wire up two endpoints:
|
||||
/hook/zerto-pre -> this script with -Phase pre (pass via args)
|
||||
/hook/zerto-post -> this script with -Phase post
|
||||
|
||||
Or one endpoint per phase, each pointing at this script. The script reads
|
||||
`phase` from the JSON body, so the -Phase param is optional.
|
||||
|
||||
.NOTES
|
||||
Compatible with:
|
||||
- Slack Incoming Webhooks (posts {"text": "..."})
|
||||
- Teams legacy connector "Incoming Webhook" (same body shape)
|
||||
- Discord webhooks (use ?wait=true for body, but text is "content" not
|
||||
"text" - tweak below)
|
||||
|
||||
Endpoint config:
|
||||
ExecutorType: WindowsPowerShell or PowerShell 7
|
||||
ScriptPath: C:\scripts\zerto-receiver-notify.ps1
|
||||
DataPassing: [x] Stdin JSON
|
||||
ResponseMode: async (we don't need to block the VPG on a chat post)
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string] $NotifyUrl = $env:NOTIFY_URL # set on the Webhook Server host, or hardcode below
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
if (-not $NotifyUrl) {
|
||||
# Fall back to a hardcoded URL if NOTIFY_URL env var isn't set.
|
||||
# Replace with your Slack/Teams Incoming Webhook URL.
|
||||
$NotifyUrl = 'https://hooks.slack.com/services/REPLACE/ME/HERE'
|
||||
}
|
||||
|
||||
$body = [Console]::In.ReadToEnd()
|
||||
if ([string]::IsNullOrWhiteSpace($body)) {
|
||||
Write-Error 'Empty stdin - expected JSON body from the webhook server.'
|
||||
exit 2
|
||||
}
|
||||
$p = $body | ConvertFrom-Json
|
||||
|
||||
$z = $p.zerto
|
||||
$phase = if ($p.phase) { $p.phase } else { 'unknown' }
|
||||
$op = if ($z.operation) { $z.operation } else { 'unknown' }
|
||||
|
||||
# Pick an icon based on operation. Test is benign; Failover/Move are real.
|
||||
$icon = switch ($op) {
|
||||
'Test' { ':test_tube:' }
|
||||
'Failover' { ':rotating_light:' }
|
||||
'Move' { ':truck:' }
|
||||
default { ':information_source:' }
|
||||
}
|
||||
|
||||
$forceTag = if ($phase -eq 'pre' -and $z.force -eq 'Yes') { ' *(FORCE)*' } else { '' }
|
||||
|
||||
$lines = @(
|
||||
"$icon *Zerto $op* - phase: ``$phase``$forceTag"
|
||||
"VPG: ``$($z.vpgName)``"
|
||||
"VMs: ``$($z.vmDisplayNames)``"
|
||||
"Hypervisor mgr: ``$($z.hypervisorManagerIP):$($z.hypervisorManagerPort)``"
|
||||
"Captured: $($p.capturedAt) (from $($p.host))"
|
||||
)
|
||||
$text = $lines -join "`n"
|
||||
|
||||
$payload = @{ text = $text } | ConvertTo-Json -Compress
|
||||
|
||||
try {
|
||||
Invoke-RestMethod -Method Post -Uri $NotifyUrl `
|
||||
-ContentType 'application/json' -Body $payload -TimeoutSec 10 | Out-Null
|
||||
Write-Host "[$phase] notified $op for VPG '$($z.vpgName)'"
|
||||
}
|
||||
catch {
|
||||
Write-Error "Notification post failed: $($_.Exception.Message)"
|
||||
exit 1
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Webhook-server-side receiver: post-failover VM health check. Pings each
|
||||
VM in the VPG and probes a configurable TCP port; writes a per-run
|
||||
report to disk.
|
||||
|
||||
.DESCRIPTION
|
||||
Intended for the POST-recovery webhook only - on a Test or real Failover,
|
||||
once the VMs are powered on at the recovery site, we can spot-check that
|
||||
they responded to ICMP and that a known port is listening (RDP, SSH,
|
||||
HTTP, etc).
|
||||
|
||||
Skips itself entirely on the pre-recovery phase (nothing's running yet)
|
||||
and on $z.operation values that don't bring VMs up.
|
||||
|
||||
Wire up one endpoint:
|
||||
/hook/zerto-post -> this script
|
||||
DataPassing: [x] Stdin JSON
|
||||
ResponseMode: async
|
||||
|
||||
.NOTES
|
||||
VmDisplayNames is a comma-separated list for multi-VM VPGs; some Zerto
|
||||
versions wrap each name in parentheses (e.g. "vm1(1)(1)(1)") to disambig
|
||||
after Test failover. We strip the trailing parenthesised suffixes when
|
||||
resolving DNS so the recovered hostname is what we ping.
|
||||
|
||||
Endpoint config:
|
||||
ExecutorType: WindowsPowerShell or PowerShell 7
|
||||
ScriptPath: C:\scripts\zerto-receiver-vm-healthcheck.ps1
|
||||
DataPassing: [x] Stdin JSON
|
||||
ResponseMode: async
|
||||
TimeoutSeconds: 120 (this script does network I/O - bump from default)
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[int] $ProbePort = 3389, # RDP. Use 22 for Linux, 80/443 for web tier.
|
||||
[int] $PingTimeout = 2000, # ms
|
||||
[string] $ReportDir = 'C:\ProgramData\WebhookServer\zerto-healthchecks'
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# --- read + parse payload -------------------------------------------------
|
||||
$body = [Console]::In.ReadToEnd()
|
||||
if ([string]::IsNullOrWhiteSpace($body)) {
|
||||
Write-Error 'Empty stdin.'
|
||||
exit 2
|
||||
}
|
||||
$p = $body | ConvertFrom-Json
|
||||
|
||||
$z = $p.zerto
|
||||
$phase = $p.phase
|
||||
$op = $z.operation
|
||||
|
||||
# Skip if this isn't a post-phase run for an op that powers VMs on.
|
||||
if ($phase -ne 'post') {
|
||||
Write-Host "Phase '$phase' - nothing to check yet, skipping."
|
||||
exit 0
|
||||
}
|
||||
if ($op -notin @('Test','Failover','Move','FailoverBeforeCommit','FailoverDuringCommit')) {
|
||||
Write-Host "Operation '$op' doesn't bring VMs up; skipping."
|
||||
exit 0
|
||||
}
|
||||
|
||||
# --- parse VM list --------------------------------------------------------
|
||||
function Strip-ZertoSuffix {
|
||||
param([string] $name)
|
||||
# "ubuntu-2404(1)(1)(1)" -> "ubuntu-2404"
|
||||
return ($name -replace '(\([^)]*\))+\s*$','').Trim()
|
||||
}
|
||||
|
||||
$rawNames = ($z.vmDisplayNames -split '[,;]') | ForEach-Object { $_.Trim() } |
|
||||
Where-Object { $_ }
|
||||
if (-not $rawNames) {
|
||||
Write-Warning 'No VM display names in payload - nothing to check.'
|
||||
exit 0
|
||||
}
|
||||
|
||||
# --- run checks -----------------------------------------------------------
|
||||
$results = foreach ($raw in $rawNames) {
|
||||
$clean = Strip-ZertoSuffix $raw
|
||||
$pingOk = $false
|
||||
$portOk = $false
|
||||
$err = $null
|
||||
|
||||
try {
|
||||
$pingOk = (Test-Connection -ComputerName $clean -Count 1 -Quiet `
|
||||
-TimeoutSeconds ([math]::Max(1, [int]($PingTimeout / 1000))) `
|
||||
-ErrorAction Stop)
|
||||
} catch { $err = "ping: $($_.Exception.Message)" }
|
||||
|
||||
try {
|
||||
$portOk = (Test-NetConnection -ComputerName $clean -Port $ProbePort `
|
||||
-InformationLevel Quiet -WarningAction SilentlyContinue)
|
||||
} catch { $err = ($err, "port: $($_.Exception.Message)") -ne $null -join '; ' }
|
||||
|
||||
[pscustomobject]@{
|
||||
DisplayName = $raw
|
||||
Resolved = $clean
|
||||
PingOk = $pingOk
|
||||
PortOk = $portOk
|
||||
ProbePort = $ProbePort
|
||||
Error = $err
|
||||
}
|
||||
}
|
||||
|
||||
# --- write report ---------------------------------------------------------
|
||||
if (-not (Test-Path $ReportDir)) {
|
||||
New-Item -ItemType Directory -Path $ReportDir -Force | Out-Null
|
||||
}
|
||||
|
||||
$safeVpg = ($z.vpgName -replace '[^A-Za-z0-9_.-]','_')
|
||||
$stamp = (Get-Date).ToUniversalTime().ToString('yyyyMMddTHHmmssZ')
|
||||
$file = Join-Path $ReportDir "$safeVpg-$op-$stamp.json"
|
||||
|
||||
$report = [ordered]@{
|
||||
vpgName = $z.vpgName
|
||||
operation = $op
|
||||
phase = $phase
|
||||
capturedAt = $p.capturedAt
|
||||
completedAt = (Get-Date).ToUniversalTime().ToString('o')
|
||||
probePort = $ProbePort
|
||||
vms = $results
|
||||
summary = @{
|
||||
total = $results.Count
|
||||
pingFailures = ($results | Where-Object { -not $_.PingOk }).Count
|
||||
portFailures = ($results | Where-Object { -not $_.PortOk }).Count
|
||||
}
|
||||
}
|
||||
$report | ConvertTo-Json -Depth 5 | Set-Content -Path $file -Encoding utf8
|
||||
|
||||
# Console output goes back via the webhook callback (if configured) so the
|
||||
# Zerto-side script log shows a quick summary even though the call is async.
|
||||
$bad = $report.summary.pingFailures + $report.summary.portFailures
|
||||
Write-Host "[$op/$phase] $($z.vpgName): $($results.Count) VM(s), $bad issue(s). Report: $file"
|
||||
|
||||
# Exit non-zero if anything failed, so the webhook server's failOnNonZeroExit
|
||||
# turns this into a 502 for the caller (and shows up in the run history).
|
||||
if ($bad -gt 0) { exit 1 }
|
||||
@@ -0,0 +1,74 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Zerto pre/post script (ZVMA / Linux scripts-service edition). Reads the
|
||||
Zerto-injected environment variables and POSTs them to a Webhook Server
|
||||
endpoint as a structured JSON payload.
|
||||
|
||||
.DESCRIPTION
|
||||
Drop into a VPG's Recovery Scripts in the ZVM UI:
|
||||
VPG settings -> Recovery -> Scripts -> Pre / Post Recovery Script
|
||||
Path: /app/scripts-files/zerto-zvma-send.ps1
|
||||
Parameters: -Phase pre (or -Phase post on the post-recovery slot)
|
||||
|
||||
Configure $WebhookUrl + $Bearer (or use the -WebhookUrl / -Bearer params
|
||||
so one script file can serve multiple VPGs / endpoints).
|
||||
|
||||
Async by default - the call returns 202 in milliseconds and the actual
|
||||
work runs in the webhook server's background, so the VPG sequence is
|
||||
never blocked by slow downstream actions (DNS, notifications, etc.).
|
||||
|
||||
.NOTES
|
||||
The scripts-service container has pwsh 7 and curl available. This script
|
||||
uses Invoke-RestMethod to keep things native to PowerShell.
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[ValidateSet('pre', 'post')]
|
||||
[string] $Phase,
|
||||
|
||||
[string] $WebhookUrl = 'http://192.168.50.250:8080/hook/zerto-{phase}',
|
||||
[string] $Bearer = '',
|
||||
[int] $TimeoutSec = 10
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# Resolve {phase} placeholder so one URL template can route to /hook/zerto-pre
|
||||
# and /hook/zerto-post. Plain URLs without the token work too.
|
||||
$url = $WebhookUrl.Replace('{phase}', $Phase)
|
||||
|
||||
$payload = [ordered]@{
|
||||
phase = $Phase
|
||||
capturedAt = (Get-Date).ToUniversalTime().ToString('o')
|
||||
host = $env:HOSTNAME # scripts-service pod name
|
||||
zerto = [ordered]@{
|
||||
vpgName = $env:ZertoVPGName
|
||||
internalVpgName = $env:ZertoInternalVpgName
|
||||
operation = $env:ZertoOperation # Test / Failover / Move / ...
|
||||
force = $env:ZertoForce # only meaningful pre
|
||||
vmDisplayNames = $env:VmDisplayNames # comma-separated for multi-VM VPGs
|
||||
hypervisorManagerIP = $env:ZertoHypervisorManagerIP
|
||||
hypervisorManagerPort = $env:ZertoHypervisorManagerPort
|
||||
outputDir = $env:ZertoOutputDir
|
||||
workingDir = $env:ZertoWorkingDir
|
||||
}
|
||||
}
|
||||
|
||||
$body = $payload | ConvertTo-Json -Depth 4 -Compress
|
||||
|
||||
$headers = @{ 'Content-Type' = 'application/json' }
|
||||
if ($Bearer) { $headers['Authorization'] = "Bearer $Bearer" }
|
||||
|
||||
try {
|
||||
$resp = Invoke-RestMethod -Method Post -Uri $url -Headers $headers `
|
||||
-Body $body -TimeoutSec $TimeoutSec
|
||||
Write-Host "[$Phase] webhook accepted: $($resp | ConvertTo-Json -Compress)"
|
||||
}
|
||||
catch {
|
||||
# Pre/post failures should not block the VPG operation. Log loudly and exit 0
|
||||
# so Zerto's recovery sequence continues. Flip to `exit 1` if you want a
|
||||
# webhook outage to fail the failover.
|
||||
Write-Warning "[$Phase] webhook call failed: $($_.Exception.Message)"
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Generates webhook-server.ico (multi-resolution) and webhook-server.png from
|
||||
a programmatic design. Re-run after changing Draw-Icon to refresh assets.
|
||||
|
||||
.DESCRIPTION
|
||||
Renders the icon at 16/24/32/48/64/128/256 px using System.Drawing, then
|
||||
assembles a Microsoft-format ICO file with each size embedded as PNG. No
|
||||
external tools required.
|
||||
|
||||
Design: a rounded-square teal background (#0E7C66) with a stylized white
|
||||
hook shape (a "J"-like curve with an arrow tip).
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$OutputDir = (Join-Path $PSScriptRoot '..\resources')
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
|
||||
New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null
|
||||
|
||||
function New-IconBitmap([int]$size) {
|
||||
$bmp = New-Object System.Drawing.Bitmap $size, $size, ([System.Drawing.Imaging.PixelFormat]::Format32bppArgb)
|
||||
$g = [System.Drawing.Graphics]::FromImage($bmp)
|
||||
$g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
|
||||
$g.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
|
||||
$g.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality
|
||||
|
||||
# Background: rounded square in brand teal.
|
||||
$bgColor = [System.Drawing.Color]::FromArgb(0xFF, 0x0E, 0x7C, 0x66)
|
||||
$bgBrush = New-Object System.Drawing.SolidBrush $bgColor
|
||||
$radius = [int]($size * 0.22)
|
||||
$rect = New-Object System.Drawing.RectangleF 0, 0, $size, $size
|
||||
|
||||
$path = New-Object System.Drawing.Drawing2D.GraphicsPath
|
||||
$d = $radius * 2
|
||||
$path.AddArc($rect.X, $rect.Y, $d, $d, 180, 90)
|
||||
$path.AddArc($rect.Right - $d, $rect.Y, $d, $d, 270, 90)
|
||||
$path.AddArc($rect.Right - $d, $rect.Bottom - $d, $d, $d, 0, 90)
|
||||
$path.AddArc($rect.X, $rect.Bottom - $d, $d, $d, 90, 90)
|
||||
$path.CloseFigure()
|
||||
$g.FillPath($bgBrush, $path)
|
||||
|
||||
# Foreground: white hook shape - a thick curved stroke shaped like a "J"
|
||||
# tipped with an arrowhead, sized relative to the canvas.
|
||||
$fgColor = [System.Drawing.Color]::White
|
||||
$stroke = [Math]::Max(2, [int]($size * 0.12))
|
||||
$pen = New-Object System.Drawing.Pen $fgColor, $stroke
|
||||
$pen.StartCap = [System.Drawing.Drawing2D.LineCap]::Round
|
||||
$pen.EndCap = [System.Drawing.Drawing2D.LineCap]::Round
|
||||
$pen.LineJoin = [System.Drawing.Drawing2D.LineJoin]::Round
|
||||
|
||||
# Hook curve: vertical down-stroke on the right, then a half-circle arc
|
||||
# curling to the left and ending in a small filled dot for the hook tip.
|
||||
$cx = [single]($size * 0.62)
|
||||
$top = [single]($size * 0.22)
|
||||
$bottom = [single]($size * 0.58)
|
||||
$arcD = [single]($size * 0.34) # arc diameter
|
||||
$arcLeft = [single]($cx - $arcD) # left edge of arc circle
|
||||
|
||||
# Vertical stroke from (cx, top) to (cx, bottom).
|
||||
$g.DrawLine($pen, $cx, $top, $cx, $bottom)
|
||||
|
||||
# Half-circle arc beneath: starts at (cx, bottom), curls to (cx - arcD, bottom).
|
||||
$arcRect = New-Object System.Drawing.RectangleF $arcLeft, ([single]($bottom - $arcD / 2)), $arcD, $arcD
|
||||
$g.DrawArc($pen, $arcRect, 0, 180)
|
||||
|
||||
# Filled circle at the tip end of the arc.
|
||||
$tipR = [single]($stroke * 0.6)
|
||||
$tipX = $arcLeft
|
||||
$tipY = [single]($bottom)
|
||||
$brushFg = New-Object System.Drawing.SolidBrush $fgColor
|
||||
$g.FillEllipse($brushFg, [single]($tipX - $tipR), [single]($tipY - $tipR), [single]($tipR * 2), [single]($tipR * 2))
|
||||
|
||||
$brushFg.Dispose(); $pen.Dispose(); $bgBrush.Dispose(); $path.Dispose()
|
||||
$g.Dispose()
|
||||
return $bmp
|
||||
}
|
||||
|
||||
# Generate each size as PNG bytes. Hashtable keys are prefixed with "s" because
|
||||
# PowerShell hashtable lookups by integer key behave inconsistently with PSObject
|
||||
# wrapping; string keys round-trip cleanly.
|
||||
$sizes = @(16, 24, 32, 48, 64, 128, 256)
|
||||
$pngs = @{}
|
||||
foreach ($s in $sizes) {
|
||||
$bmp = New-IconBitmap $s
|
||||
$ms = New-Object System.IO.MemoryStream
|
||||
$bmp.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)
|
||||
$pngs["s$s"] = $ms.ToArray()
|
||||
$ms.Dispose()
|
||||
$bmp.Dispose()
|
||||
}
|
||||
|
||||
# Save the master 256 PNG separately for places that need a transparent PNG.
|
||||
$pngPath = Join-Path $OutputDir 'webhook-server.png'
|
||||
[System.IO.File]::WriteAllBytes($pngPath, [byte[]]$pngs['s256'])
|
||||
|
||||
# Assemble multi-resolution ICO.
|
||||
$icoPath = Join-Path $OutputDir 'webhook-server.ico'
|
||||
$ms = New-Object System.IO.MemoryStream
|
||||
$bw = New-Object System.IO.BinaryWriter $ms
|
||||
try {
|
||||
$count = $sizes.Count
|
||||
$bw.Write([UInt16]0) # idReserved
|
||||
$bw.Write([UInt16]1) # idType: 1 = ICO
|
||||
$bw.Write([UInt16]$count) # idCount
|
||||
|
||||
# Directory entries (16 bytes each).
|
||||
$offset = 6 + 16 * $count
|
||||
foreach ($s in $sizes) {
|
||||
$bytes = $pngs["s$s"]
|
||||
$w = if ($s -ge 256) { 0 } else { $s }
|
||||
$h = if ($s -ge 256) { 0 } else { $s }
|
||||
$bw.Write([byte]$w) # width
|
||||
$bw.Write([byte]$h) # height
|
||||
$bw.Write([byte]0) # colorCount
|
||||
$bw.Write([byte]0) # reserved
|
||||
$bw.Write([UInt16]1) # planes
|
||||
$bw.Write([UInt16]32) # bitCount
|
||||
$bw.Write([UInt32]$bytes.Length)
|
||||
$bw.Write([UInt32]$offset)
|
||||
$offset += $bytes.Length
|
||||
}
|
||||
|
||||
# Image data.
|
||||
foreach ($s in $sizes) { $bw.Write($pngs["s$s"]) }
|
||||
|
||||
$bw.Flush()
|
||||
[System.IO.File]::WriteAllBytes($icoPath, $ms.ToArray())
|
||||
}
|
||||
finally {
|
||||
$bw.Dispose(); $ms.Dispose()
|
||||
}
|
||||
|
||||
Write-Host "Wrote $icoPath ($((Get-Item $icoPath).Length) bytes)"
|
||||
Write-Host "Wrote $pngPath ($((Get-Item $pngPath).Length) bytes)"
|
||||
@@ -0,0 +1,90 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Installs and starts the WebhookServer Windows Service.
|
||||
|
||||
.DESCRIPTION
|
||||
Creates the service via sc.exe pointed at the published WebhookServer.Service.exe,
|
||||
sets it to start automatically, and starts it. Re-running the script with the same
|
||||
BinaryPath updates the binary path of an existing service.
|
||||
|
||||
.PARAMETER BinaryPath
|
||||
Full path to WebhookServer.Service.exe. Defaults to .\publish\WebhookServer.Service.exe
|
||||
relative to the script.
|
||||
|
||||
.PARAMETER ServiceAccount
|
||||
Account to run the service under. Defaults to LocalSystem.
|
||||
For Active-Directory-aware hooks pass a domain user (DOMAIN\user) or a gMSA
|
||||
(DOMAIN\svc-name$ - note the trailing $). Domain users require -Password.
|
||||
Never pass LocalService - it has no network identity and cannot reach a DC.
|
||||
|
||||
.PARAMETER Password
|
||||
Password for a domain-user account. Not required for LocalSystem, NetworkService,
|
||||
LocalService, or gMSA accounts.
|
||||
|
||||
.EXAMPLE
|
||||
.\install-service.ps1 -BinaryPath C:\WebhookServer\WebhookServer.Service.exe
|
||||
|
||||
.EXAMPLE
|
||||
.\install-service.ps1 -BinaryPath C:\WebhookServer\WebhookServer.Service.exe `
|
||||
-ServiceAccount 'CONTOSO\svc-webhookserver$'
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$BinaryPath = (Join-Path $PSScriptRoot '..\publish\WebhookServer.Service.exe'),
|
||||
[string]$ServiceName = 'WebhookServer',
|
||||
[string]$DisplayName = 'Webhook Server',
|
||||
[string]$ServiceAccount = 'LocalSystem',
|
||||
[string]$Password
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
if ($ServiceAccount -ieq 'LocalService') {
|
||||
throw 'LocalService has no network identity and cannot talk to a domain controller. Use LocalSystem, a domain user, or a gMSA instead.'
|
||||
}
|
||||
|
||||
$BinaryPath = (Resolve-Path -LiteralPath $BinaryPath).Path
|
||||
if (-not (Test-Path -LiteralPath $BinaryPath)) {
|
||||
throw "Binary not found: $BinaryPath"
|
||||
}
|
||||
|
||||
# sc.exe argv format: "key= value" - space AFTER equals, none before.
|
||||
$obj = $ServiceAccount
|
||||
# Get-Service returns $null when the service doesn't exist; sc.exe query is unreliable
|
||||
# because it writes a FAILED line to stdout that makes truthy checks pass.
|
||||
$existing = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
|
||||
|
||||
if ($existing) {
|
||||
Write-Host "Service '$ServiceName' already exists; updating binPath and account."
|
||||
$configArgs = @(
|
||||
'config', $ServiceName,
|
||||
'binPath=', "`"$BinaryPath`"",
|
||||
'obj=', $obj
|
||||
)
|
||||
if ($Password) { $configArgs += @('password=', $Password) }
|
||||
sc.exe @configArgs
|
||||
if ($LASTEXITCODE -ne 0) { throw "sc.exe config failed with exit code $LASTEXITCODE" }
|
||||
} else {
|
||||
Write-Host "Creating service '$ServiceName'..."
|
||||
$createArgs = @(
|
||||
'create', $ServiceName,
|
||||
'binPath=', "`"$BinaryPath`"",
|
||||
'DisplayName=', "`"$DisplayName`"",
|
||||
'start=', 'auto',
|
||||
'obj=', $obj
|
||||
)
|
||||
if ($Password) { $createArgs += @('password=', $Password) }
|
||||
sc.exe @createArgs
|
||||
if ($LASTEXITCODE -ne 0) { throw "sc.exe create failed with exit code $LASTEXITCODE" }
|
||||
}
|
||||
|
||||
# Configure failure recovery: restart the service on first/second failure, reset count after a day.
|
||||
sc.exe failure $ServiceName reset= 86400 actions= restart/5000/restart/5000/restart/5000
|
||||
if ($LASTEXITCODE -ne 0) { Write-Warning "sc.exe failure returned $LASTEXITCODE (recovery actions may not be set)" }
|
||||
|
||||
Write-Host "Starting service '$ServiceName'..."
|
||||
sc.exe start $ServiceName
|
||||
if ($LASTEXITCODE -ne 0) { throw "sc.exe start failed with exit code $LASTEXITCODE - check Event Viewer (Windows Logs > Application) for details" }
|
||||
|
||||
Start-Sleep -Seconds 1
|
||||
sc.exe query $ServiceName
|
||||
@@ -0,0 +1,159 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Mirrors the in-repo docs/ folder to a GitHub or Gitea wiki repo.
|
||||
|
||||
.DESCRIPTION
|
||||
Wikis are separate git repositories (e.g. <repo>.wiki.git) with a flat URL
|
||||
structure. This script:
|
||||
|
||||
1. Clones the wiki repo into a temp directory.
|
||||
2. Wipes its existing .md content.
|
||||
3. Copies each docs/*.md to a flattened wiki-style page name.
|
||||
4. Rewrites in-repo markdown links so they point at the wiki page slugs.
|
||||
5. Generates a _Sidebar.md so every wiki page has a navigation sidebar.
|
||||
6. Commits and pushes back if anything changed.
|
||||
|
||||
Idempotent. Safe to re-run.
|
||||
|
||||
.PARAMETER WikiUrl
|
||||
Full HTTPS URL to the wiki repo, including any embedded credentials. Examples:
|
||||
https://github.com/recklessop/webhook-server.wiki.git
|
||||
https://x-access-token:$TOKEN@github.com/recklessop/webhook-server.wiki.git
|
||||
https://justin:$GITEA_TOKEN@git.jpaul.io/justin/webhook-server.wiki.git
|
||||
|
||||
.PARAMETER AuthorName
|
||||
git committer name. Defaults to "Webhook Server Wiki Sync".
|
||||
|
||||
.PARAMETER AuthorEmail
|
||||
git committer email. Defaults to "noreply@jpaul.me".
|
||||
|
||||
.EXAMPLE
|
||||
# Manual sync to Gitea (token in env)
|
||||
$env:GITEA_TOKEN = '...'
|
||||
./scripts/sync-wiki.ps1 -WikiUrl "https://justin:$env:GITEA_TOKEN@git.jpaul.io/justin/webhook-server.wiki.git"
|
||||
|
||||
.EXAMPLE
|
||||
# Manual sync to GitHub (gh-issued token)
|
||||
$token = & gh auth token
|
||||
./scripts/sync-wiki.ps1 -WikiUrl "https://x-access-token:$token@github.com/recklessop/webhook-server.wiki.git"
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$WikiUrl,
|
||||
[string]$AuthorName = 'Webhook Server Wiki Sync',
|
||||
[string]$AuthorEmail = 'noreply@jpaul.me'
|
||||
)
|
||||
|
||||
# Continue (not Stop) because git writes informational messages to stderr
|
||||
# (CRLF warnings, "remote: Processed N references" etc.) which PowerShell 5.1
|
||||
# escalates to a script-fatal error under Stop. We check $LASTEXITCODE
|
||||
# manually after each git call instead.
|
||||
$ErrorActionPreference = 'Continue'
|
||||
|
||||
$repoRoot = Split-Path -Parent $PSScriptRoot
|
||||
$docsDir = Join-Path $repoRoot 'docs'
|
||||
$workDir = Join-Path $env:TEMP ("webhook-wiki-{0}" -f ([guid]::NewGuid().ToString('N').Substring(0, 8)))
|
||||
|
||||
# Source path (relative to docs/) -> wiki page slug. Order matters for the sidebar.
|
||||
$mapping = [ordered]@{}
|
||||
$mapping.Add('README.md', 'Home')
|
||||
$mapping.Add('concepts.md', 'Concepts')
|
||||
$mapping.Add('installation.md', 'Installation')
|
||||
$mapping.Add('upgrading.md', 'Upgrading')
|
||||
$mapping.Add('uninstalling.md', 'Uninstalling')
|
||||
$mapping.Add('runas-modes.md', 'Run-As-Modes')
|
||||
$mapping.Add('service-account-and-ad.md', 'Service-Account-and-AD')
|
||||
$mapping.Add('network-and-security.md', 'Network-and-Security')
|
||||
$mapping.Add('troubleshooting.md', 'Troubleshooting')
|
||||
$mapping.Add('recipes/zerto-pre-post-scripts.md', 'Recipe-Zerto-Failover')
|
||||
$mapping.Add('recipes/github-style-hmac.md', 'Recipe-GitHub-HMAC')
|
||||
$mapping.Add('recipes/ui-on-desktop.md', 'Recipe-UI-on-Desktop')
|
||||
|
||||
function Rewrite-Links([string]$content) {
|
||||
foreach ($m in $mapping.GetEnumerator()) {
|
||||
# Match (path/to/file.md) and (path/to/file.md#anchor) inside markdown
|
||||
# link parens. The lookbehind ensures we're consuming a real link target.
|
||||
$escaped = [regex]::Escape($m.Key)
|
||||
$content = [regex]::Replace($content,
|
||||
"\(\.?\.?/?$escaped(\#[^)\s]*)?\)",
|
||||
"($($m.Value)`$1)")
|
||||
}
|
||||
# Also clean up doubled prefixes like "../../docs/" or "../" pointers that
|
||||
# sometimes appear in cross-folder relative links from docs/recipes/.
|
||||
return $content
|
||||
}
|
||||
|
||||
function New-Sidebar() {
|
||||
$lines = @()
|
||||
$lines += "[Home](Home)"
|
||||
$lines += ""
|
||||
$lines += "## Topical"
|
||||
foreach ($key in @('concepts.md','installation.md','upgrading.md','uninstalling.md','runas-modes.md','service-account-and-ad.md','network-and-security.md','troubleshooting.md')) {
|
||||
$slug = $mapping[$key]
|
||||
$lines += "- [$($slug -replace '-', ' ')]($slug)"
|
||||
}
|
||||
$lines += ""
|
||||
$lines += "## Recipes"
|
||||
foreach ($key in @('recipes/zerto-pre-post-scripts.md','recipes/github-style-hmac.md','recipes/ui-on-desktop.md')) {
|
||||
$slug = $mapping[$key]
|
||||
$lines += "- [$($slug -replace '^Recipe-' -replace '-', ' ')]($slug)"
|
||||
}
|
||||
return ($lines -join "`n")
|
||||
}
|
||||
|
||||
# 1. Clone the wiki.
|
||||
Write-Host "Cloning wiki to $workDir..."
|
||||
& git clone --quiet $WikiUrl $workDir 2>&1 | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "git clone failed. Has the wiki been initialized? Visit the repo's Wiki tab and create the first page via the UI before running this script."
|
||||
}
|
||||
# Suppress git's CRLF nags for this throwaway clone so they don't become
|
||||
# "errors" via PowerShell's native-command stderr handling.
|
||||
& git -C $workDir config core.autocrlf false 2>&1 | Out-Null
|
||||
& git -C $workDir config core.safecrlf false 2>&1 | Out-Null
|
||||
|
||||
try {
|
||||
Push-Location $workDir
|
||||
try {
|
||||
# 2. Wipe existing markdown so removed source files vanish from the wiki.
|
||||
Get-ChildItem -Filter "*.md" -Force | Remove-Item -Force
|
||||
|
||||
# 3. Copy + transform each source file.
|
||||
$written = 0
|
||||
foreach ($entry in $mapping.GetEnumerator()) {
|
||||
$src = Join-Path $docsDir $entry.Key
|
||||
$dst = Join-Path $workDir "$($entry.Value).md"
|
||||
if (-not (Test-Path $src)) {
|
||||
Write-Warning "Source missing, skipping: $src"
|
||||
continue
|
||||
}
|
||||
$content = Get-Content -LiteralPath $src -Raw
|
||||
$content = Rewrite-Links $content
|
||||
Set-Content -LiteralPath $dst -Value $content -Encoding utf8 -NoNewline
|
||||
$written++
|
||||
}
|
||||
Write-Host "Wrote $written markdown pages."
|
||||
|
||||
# 4. Sidebar
|
||||
Set-Content -LiteralPath (Join-Path $workDir '_Sidebar.md') -Value (New-Sidebar) -Encoding utf8 -NoNewline
|
||||
|
||||
# 5. Commit + push if anything actually changed. Drain stderr from each
|
||||
# git invocation so PowerShell doesn't treat warnings as errors.
|
||||
& git add -A 2>&1 | Out-Null
|
||||
$changes = & git status --porcelain 2>&1
|
||||
if (-not $changes) {
|
||||
Write-Host "Wiki already up to date."
|
||||
return
|
||||
}
|
||||
$sha = & git -C $repoRoot rev-parse --short HEAD 2>&1
|
||||
& git -c "user.name=$AuthorName" -c "user.email=$AuthorEmail" commit -q -m "Sync from docs/ at $sha" 2>&1 | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) { throw "git commit failed (exit $LASTEXITCODE)" }
|
||||
& git push --quiet 2>&1 | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) { throw "git push failed (exit $LASTEXITCODE)" }
|
||||
Write-Host "Pushed updated wiki."
|
||||
}
|
||||
finally { Pop-Location }
|
||||
}
|
||||
finally {
|
||||
Remove-Item -Recurse -Force $workDir -ErrorAction SilentlyContinue
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Stops and removes the WebhookServer Windows Service.
|
||||
|
||||
.DESCRIPTION
|
||||
Leaves C:\ProgramData\WebhookServer (config + logs) untouched. Pass -PurgeData
|
||||
to remove that directory as well.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$ServiceName = 'WebhookServer',
|
||||
[switch]$PurgeData
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$existing = sc.exe query $ServiceName 2>$null
|
||||
if (-not $existing) {
|
||||
Write-Host "Service '$ServiceName' is not installed."
|
||||
return
|
||||
}
|
||||
|
||||
Write-Host "Stopping service '$ServiceName'..."
|
||||
sc.exe stop $ServiceName 2>$null | Out-Null
|
||||
Start-Sleep -Seconds 2
|
||||
|
||||
Write-Host "Deleting service '$ServiceName'..."
|
||||
sc.exe delete $ServiceName | Out-Null
|
||||
|
||||
if ($PurgeData) {
|
||||
$dataRoot = Join-Path $env:ProgramData 'WebhookServer'
|
||||
if (Test-Path -LiteralPath $dataRoot) {
|
||||
Write-Host "Removing $dataRoot"
|
||||
Remove-Item -LiteralPath $dataRoot -Recurse -Force
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host 'Done.'
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace WebhookServer.Core.Auth;
|
||||
|
||||
public readonly record struct AuthResult(bool Success, string? Reason)
|
||||
{
|
||||
public static AuthResult Ok() => new(true, null);
|
||||
public static AuthResult Fail(string reason) => new(false, reason);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace WebhookServer.Core.Auth;
|
||||
|
||||
public static class BearerVerifier
|
||||
{
|
||||
private const string Prefix = "Bearer ";
|
||||
|
||||
/// <summary>
|
||||
/// Compares the value of an Authorization header against an expected secret in fixed time.
|
||||
/// </summary>
|
||||
public static AuthResult Verify(string? authorizationHeader, string expectedSecret)
|
||||
{
|
||||
if (string.IsNullOrEmpty(expectedSecret))
|
||||
return AuthResult.Fail("server secret not configured");
|
||||
|
||||
if (string.IsNullOrEmpty(authorizationHeader))
|
||||
return AuthResult.Fail("missing Authorization header");
|
||||
|
||||
if (!authorizationHeader.StartsWith(Prefix, StringComparison.Ordinal))
|
||||
return AuthResult.Fail("Authorization header is not a Bearer token");
|
||||
|
||||
var presented = authorizationHeader.AsSpan(Prefix.Length).Trim();
|
||||
var presentedBytes = Encoding.UTF8.GetBytes(presented.ToString());
|
||||
var expectedBytes = Encoding.UTF8.GetBytes(expectedSecret);
|
||||
|
||||
return CryptographicOperations.FixedTimeEquals(presentedBytes, expectedBytes)
|
||||
? AuthResult.Ok()
|
||||
: AuthResult.Fail("bearer token mismatch");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using WebhookServer.Core.Models;
|
||||
|
||||
namespace WebhookServer.Core.Auth;
|
||||
|
||||
public static class HmacVerifier
|
||||
{
|
||||
/// <summary>
|
||||
/// Compute the signature string (encoded per <paramref name="encoding"/>, no prefix)
|
||||
/// for the given body bytes and shared secret.
|
||||
/// </summary>
|
||||
public static string Compute(
|
||||
ReadOnlySpan<byte> body,
|
||||
string secret,
|
||||
HmacAlgorithm algorithm,
|
||||
HmacEncoding encoding)
|
||||
{
|
||||
var keyBytes = Encoding.UTF8.GetBytes(secret);
|
||||
Span<byte> hash = stackalloc byte[64]; // SHA-512 is 64 bytes max
|
||||
int written = algorithm switch
|
||||
{
|
||||
HmacAlgorithm.Sha1 => HMACSHA1.HashData(keyBytes, body, hash),
|
||||
HmacAlgorithm.Sha256 => HMACSHA256.HashData(keyBytes, body, hash),
|
||||
HmacAlgorithm.Sha512 => HMACSHA512.HashData(keyBytes, body, hash),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(algorithm)),
|
||||
};
|
||||
|
||||
var hashBytes = hash[..written];
|
||||
return encoding switch
|
||||
{
|
||||
HmacEncoding.Hex => Convert.ToHexString(hashBytes).ToLowerInvariant(),
|
||||
HmacEncoding.Base64 => Convert.ToBase64String(hashBytes),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(encoding)),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify the HMAC signature in <paramref name="presentedHeaderValue"/> against the
|
||||
/// computed signature for <paramref name="body"/>. Strips the configured prefix
|
||||
/// before comparing. Comparison is constant time.
|
||||
/// </summary>
|
||||
public static AuthResult Verify(
|
||||
ReadOnlySpan<byte> body,
|
||||
string? presentedHeaderValue,
|
||||
HmacOptions options)
|
||||
{
|
||||
if (options.Secret.Plaintext is not { Length: > 0 } secret)
|
||||
return AuthResult.Fail("HMAC secret not available");
|
||||
|
||||
if (string.IsNullOrEmpty(presentedHeaderValue))
|
||||
return AuthResult.Fail($"missing {options.HeaderName} header");
|
||||
|
||||
var presented = presentedHeaderValue.AsSpan().Trim();
|
||||
if (!string.IsNullOrEmpty(options.Prefix))
|
||||
{
|
||||
if (!presented.StartsWith(options.Prefix, StringComparison.OrdinalIgnoreCase))
|
||||
return AuthResult.Fail("signature prefix mismatch");
|
||||
presented = presented[options.Prefix.Length..];
|
||||
}
|
||||
|
||||
var expected = Compute(body, secret, options.Algorithm, options.Encoding);
|
||||
|
||||
// Encoding for hex is case-insensitive in practice; normalize to lower.
|
||||
var presentedNormalized = options.Encoding == HmacEncoding.Hex
|
||||
? presented.ToString().ToLowerInvariant()
|
||||
: presented.ToString();
|
||||
|
||||
var presentedBytes = Encoding.ASCII.GetBytes(presentedNormalized);
|
||||
var expectedBytes = Encoding.ASCII.GetBytes(expected);
|
||||
|
||||
return CryptographicOperations.FixedTimeEquals(presentedBytes, expectedBytes)
|
||||
? AuthResult.Ok()
|
||||
: AuthResult.Fail("HMAC signature mismatch");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace WebhookServer.Core.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Compiled allow-list of IPs and CIDR ranges. Empty list = allow all.
|
||||
/// </summary>
|
||||
public sealed class IpAllowList
|
||||
{
|
||||
private readonly List<IPNetwork> _networks;
|
||||
|
||||
public bool IsEmpty => _networks.Count == 0;
|
||||
|
||||
private IpAllowList(List<IPNetwork> networks) => _networks = networks;
|
||||
|
||||
public bool Contains(IPAddress address)
|
||||
{
|
||||
if (IsEmpty) return true;
|
||||
|
||||
var normalized = Normalize(address);
|
||||
foreach (var net in _networks)
|
||||
{
|
||||
if (net.BaseAddress.AddressFamily != normalized.AddressFamily) continue;
|
||||
if (net.Contains(normalized)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse a list of allowlist entries. Each entry may be a single IP or a CIDR.
|
||||
/// Throws <see cref="FormatException"/> on the first invalid entry.
|
||||
/// </summary>
|
||||
public static IpAllowList Parse(IEnumerable<string> entries)
|
||||
{
|
||||
var nets = new List<IPNetwork>();
|
||||
foreach (var raw in entries)
|
||||
{
|
||||
var entry = raw?.Trim();
|
||||
if (string.IsNullOrEmpty(entry)) continue;
|
||||
nets.Add(ParseEntry(entry));
|
||||
}
|
||||
return new IpAllowList(nets);
|
||||
}
|
||||
|
||||
public static bool TryParse(IEnumerable<string> entries, out IpAllowList list, out string? error)
|
||||
{
|
||||
var nets = new List<IPNetwork>();
|
||||
foreach (var raw in entries)
|
||||
{
|
||||
var entry = raw?.Trim();
|
||||
if (string.IsNullOrEmpty(entry)) continue;
|
||||
try
|
||||
{
|
||||
nets.Add(ParseEntry(entry));
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
list = new IpAllowList(new List<IPNetwork>());
|
||||
error = $"invalid entry '{raw}': {ex.Message}";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
list = new IpAllowList(nets);
|
||||
error = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static IPNetwork ParseEntry(string entry)
|
||||
{
|
||||
if (entry.Contains('/'))
|
||||
return IPNetwork.Parse(entry);
|
||||
|
||||
if (!IPAddress.TryParse(entry, out var addr))
|
||||
throw new FormatException($"'{entry}' is not a valid IP address or CIDR");
|
||||
|
||||
var prefix = addr.AddressFamily == AddressFamily.InterNetworkV6 ? 128 : 32;
|
||||
return new IPNetwork(Normalize(addr), prefix);
|
||||
}
|
||||
|
||||
private static IPAddress Normalize(IPAddress address)
|
||||
{
|
||||
if (address.AddressFamily == AddressFamily.InterNetworkV6 && address.IsIPv4MappedToIPv6)
|
||||
return address.MapToIPv4();
|
||||
return address;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Channels;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using WebhookServer.Core.Auth;
|
||||
using WebhookServer.Core.Models;
|
||||
using WebhookServer.Core.Storage;
|
||||
|
||||
namespace WebhookServer.Core.Callbacks;
|
||||
|
||||
/// <summary>
|
||||
/// Bounded queue of pending callback deliveries with retry + backoff. Reuses
|
||||
/// <see cref="HmacVerifier.Compute"/> so outbound HMAC matches the inbound code path.
|
||||
///
|
||||
/// Run <see cref="RunAsync"/> from a single long-running task (BackgroundService in the
|
||||
/// service host); call <see cref="Enqueue"/> from anywhere. Disposing the dispatcher
|
||||
/// disposes its <see cref="HttpClient"/>.
|
||||
/// </summary>
|
||||
public sealed class CallbackDispatcher : IDisposable
|
||||
{
|
||||
private const int QueueCapacity = 1024;
|
||||
private static readonly TimeSpan MaxRetryAfter = TimeSpan.FromSeconds(60);
|
||||
|
||||
private readonly Channel<CallbackEnvelope> _channel;
|
||||
private readonly HttpClient _http;
|
||||
private readonly ILogger<CallbackDispatcher>? _logger;
|
||||
|
||||
public CallbackDispatcher(ILogger<CallbackDispatcher>? logger = null, HttpClient? httpClient = null)
|
||||
{
|
||||
_logger = logger;
|
||||
_channel = Channel.CreateBounded<CallbackEnvelope>(new BoundedChannelOptions(QueueCapacity)
|
||||
{
|
||||
FullMode = BoundedChannelFullMode.DropOldest,
|
||||
SingleReader = true,
|
||||
SingleWriter = false,
|
||||
});
|
||||
|
||||
_http = httpClient ?? new HttpClient(new SocketsHttpHandler
|
||||
{
|
||||
AllowAutoRedirect = true,
|
||||
MaxAutomaticRedirections = 3,
|
||||
});
|
||||
}
|
||||
|
||||
public bool Enqueue(CallbackEnvelope envelope)
|
||||
{
|
||||
var ok = _channel.Writer.TryWrite(envelope);
|
||||
if (!ok)
|
||||
{
|
||||
_logger?.LogWarning("Callback queue full; dropped envelope for endpoint {Slug}", envelope.EndpointSlug);
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
public async Task RunAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await foreach (var envelope in _channel.Reader.ReadAllAsync(stoppingToken).ConfigureAwait(false))
|
||||
{
|
||||
try
|
||||
{
|
||||
await DeliverAsync(envelope, stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogError(ex, "Unhandled error in callback dispatcher for {Slug}", envelope.EndpointSlug);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeliverAsync(CallbackEnvelope envelope, CancellationToken stoppingToken)
|
||||
{
|
||||
var cfg = envelope.Config;
|
||||
var maxAttempts = Math.Max(1, cfg.MaxAttempts);
|
||||
var bodyBytes = SerializePayload(envelope.Payload, cfg);
|
||||
|
||||
for (int attempt = 1; attempt <= maxAttempts; attempt++)
|
||||
{
|
||||
using var attemptCts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
|
||||
attemptCts.CancelAfter(TimeSpan.FromSeconds(Math.Max(1, cfg.TimeoutSeconds)));
|
||||
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
HttpResponseMessage? response = null;
|
||||
string? errorReason = null;
|
||||
|
||||
try
|
||||
{
|
||||
using var request = BuildRequest(envelope, bodyBytes);
|
||||
response = await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, attemptCts.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (TaskCanceledException) when (attemptCts.IsCancellationRequested && !stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
errorReason = "timeout";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorReason = ex.GetType().Name;
|
||||
}
|
||||
sw.Stop();
|
||||
|
||||
int? statusCode = (int?)response?.StatusCode;
|
||||
bool delivered = response is { IsSuccessStatusCode: true };
|
||||
|
||||
_logger?.LogInformation(
|
||||
"Callback {Slug} attempt {Attempt}/{Max} -> {Status} ({Latency} ms){Error}",
|
||||
envelope.EndpointSlug, attempt, maxAttempts,
|
||||
statusCode?.ToString() ?? "ERR",
|
||||
sw.ElapsedMilliseconds,
|
||||
errorReason is null ? "" : $" [{errorReason}]");
|
||||
|
||||
if (delivered)
|
||||
{
|
||||
response?.Dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
var transient = errorReason is not null || (statusCode.HasValue && IsRetryable(statusCode.Value));
|
||||
if (!transient || attempt == maxAttempts)
|
||||
{
|
||||
_logger?.LogWarning("Callback {Slug} {Disposition} after {Attempts} attempts",
|
||||
envelope.EndpointSlug,
|
||||
transient ? "gave-up" : "dropped",
|
||||
attempt);
|
||||
response?.Dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
var delay = ComputeBackoff(attempt, response);
|
||||
response?.Dispose();
|
||||
try { await Task.Delay(delay, stoppingToken).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { return; }
|
||||
}
|
||||
}
|
||||
|
||||
private HttpRequestMessage BuildRequest(CallbackEnvelope envelope, byte[] bodyBytes)
|
||||
{
|
||||
var cfg = envelope.Config;
|
||||
var method = cfg.Method == CallbackHttpMethod.Put ? HttpMethod.Put : HttpMethod.Post;
|
||||
var request = new HttpRequestMessage(method, cfg.Url)
|
||||
{
|
||||
Content = new ByteArrayContent(bodyBytes),
|
||||
};
|
||||
request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json") { CharSet = "utf-8" };
|
||||
|
||||
switch (cfg.AuthMode)
|
||||
{
|
||||
case AuthMode.Bearer:
|
||||
if (cfg.Bearer?.Secret.Plaintext is { Length: > 0 } token)
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
break;
|
||||
case AuthMode.Hmac:
|
||||
if (cfg.Hmac is { } hmac && hmac.Secret.Plaintext is { Length: > 0 } secret)
|
||||
{
|
||||
var sig = HmacVerifier.Compute(bodyBytes, secret, hmac.Algorithm, hmac.Encoding);
|
||||
request.Headers.TryAddWithoutValidation(hmac.HeaderName, hmac.Prefix + sig);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
private static byte[] SerializePayload(CallbackPayload payload, CallbackConfig cfg)
|
||||
{
|
||||
// Honor the IncludeStdout / IncludeStderr flags by wiping them out before serialization.
|
||||
var effective = new CallbackPayload
|
||||
{
|
||||
RunId = payload.RunId,
|
||||
Endpoint = payload.Endpoint,
|
||||
StartedAt = payload.StartedAt,
|
||||
CompletedAt = payload.CompletedAt,
|
||||
DurationMs = payload.DurationMs,
|
||||
ExitCode = payload.ExitCode,
|
||||
Succeeded = payload.Succeeded,
|
||||
TimedOut = payload.TimedOut,
|
||||
Stdout = cfg.IncludeStdout ? payload.Stdout : null,
|
||||
Stderr = cfg.IncludeStderr ? payload.Stderr : null,
|
||||
StdoutTruncated = cfg.IncludeStdout && payload.StdoutTruncated,
|
||||
StderrTruncated = cfg.IncludeStderr && payload.StderrTruncated,
|
||||
};
|
||||
|
||||
return JsonSerializer.SerializeToUtf8Bytes(effective, ConfigJson.Compact);
|
||||
}
|
||||
|
||||
private static bool IsRetryable(int status) => status switch
|
||||
{
|
||||
408 or 425 or 429 => true,
|
||||
>= 500 and <= 599 => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
private static TimeSpan ComputeBackoff(int attempt, HttpResponseMessage? response)
|
||||
{
|
||||
if (response?.Headers.RetryAfter is { } ra)
|
||||
{
|
||||
if (ra.Delta.HasValue)
|
||||
return Min(ra.Delta.Value, MaxRetryAfter);
|
||||
if (ra.Date.HasValue)
|
||||
{
|
||||
var delta = ra.Date.Value - DateTimeOffset.UtcNow;
|
||||
if (delta > TimeSpan.Zero) return Min(delta, MaxRetryAfter);
|
||||
}
|
||||
}
|
||||
|
||||
// Exponential: 1s, 2s, 4s, 8s, 16s, 32s, 60s cap
|
||||
var seconds = Math.Min(60, Math.Pow(2, attempt - 1));
|
||||
var jitter = (Random.Shared.NextDouble() * 0.5) - 0.25; // ±25%
|
||||
return TimeSpan.FromSeconds(seconds * (1 + jitter));
|
||||
}
|
||||
|
||||
private static TimeSpan Min(TimeSpan a, TimeSpan b) => a < b ? a : b;
|
||||
|
||||
public void Dispose() => _http.Dispose();
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using WebhookServer.Core.Models;
|
||||
|
||||
namespace WebhookServer.Core.Callbacks;
|
||||
|
||||
/// <summary>
|
||||
/// Internal queue item pairing a payload with the resolved <see cref="CallbackConfig"/>
|
||||
/// for the endpoint. The dispatcher reads from a channel of these.
|
||||
/// </summary>
|
||||
public sealed class CallbackEnvelope
|
||||
{
|
||||
public required Guid EndpointId { get; init; }
|
||||
public required string EndpointSlug { get; init; }
|
||||
public required CallbackConfig Config { get; init; }
|
||||
public required CallbackPayload Payload { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace WebhookServer.Core.Callbacks;
|
||||
|
||||
/// <summary>
|
||||
/// JSON body POSTed to a configured outbound callback URL.
|
||||
/// </summary>
|
||||
public sealed class CallbackPayload
|
||||
{
|
||||
[JsonPropertyName("runId")] public required string RunId { get; init; }
|
||||
[JsonPropertyName("endpoint")] public required string Endpoint { get; init; }
|
||||
[JsonPropertyName("startedAt")] public required DateTimeOffset StartedAt { get; init; }
|
||||
[JsonPropertyName("completedAt")] public required DateTimeOffset CompletedAt { get; init; }
|
||||
[JsonPropertyName("durationMs")] public required long DurationMs { get; init; }
|
||||
[JsonPropertyName("exitCode")] public required int ExitCode { get; init; }
|
||||
[JsonPropertyName("succeeded")] public required bool Succeeded { get; init; }
|
||||
[JsonPropertyName("timedOut")] public required bool TimedOut { get; init; }
|
||||
[JsonPropertyName("stdout")] public string? Stdout { get; init; }
|
||||
[JsonPropertyName("stderr")] public string? Stderr { get; init; }
|
||||
[JsonPropertyName("stdoutTruncated")] public bool StdoutTruncated { get; init; }
|
||||
[JsonPropertyName("stderrTruncated")] public bool StderrTruncated { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace WebhookServer.Core.Execution;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves {{path}} tokens against an <see cref="ExecutionContext"/>. Each whitespace-
|
||||
/// separated token in the template becomes one argv entry.
|
||||
/// Path grammar:
|
||||
/// {{body.foo.bar}} JSON path into the body
|
||||
/// {{header.X-Foo}} header by name (case-insensitive)
|
||||
/// {{query.bar}} query param
|
||||
/// {{route.slug}} route value
|
||||
/// Missing paths render as empty string.
|
||||
/// </summary>
|
||||
public static class ArgTemplateRenderer
|
||||
{
|
||||
public static List<string> Render(string? template, ExecutionContext ctx)
|
||||
{
|
||||
var args = new List<string>();
|
||||
if (string.IsNullOrWhiteSpace(template)) return args;
|
||||
|
||||
foreach (var token in template.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
args.Add(RenderToken(token, ctx));
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
private static string RenderToken(string token, ExecutionContext ctx)
|
||||
{
|
||||
// Replace every {{...}} occurrence inside the token in a single left-to-right pass.
|
||||
var result = new System.Text.StringBuilder(token.Length);
|
||||
var i = 0;
|
||||
while (i < token.Length)
|
||||
{
|
||||
var open = token.IndexOf("{{", i, StringComparison.Ordinal);
|
||||
if (open < 0)
|
||||
{
|
||||
result.Append(token, i, token.Length - i);
|
||||
break;
|
||||
}
|
||||
result.Append(token, i, open - i);
|
||||
var close = token.IndexOf("}}", open + 2, StringComparison.Ordinal);
|
||||
if (close < 0)
|
||||
{
|
||||
// Unclosed token — treat the rest as literal.
|
||||
result.Append(token, open, token.Length - open);
|
||||
break;
|
||||
}
|
||||
var path = token.Substring(open + 2, close - (open + 2)).Trim();
|
||||
result.Append(Resolve(path, ctx));
|
||||
i = close + 2;
|
||||
}
|
||||
return result.ToString();
|
||||
}
|
||||
|
||||
private static string Resolve(string path, ExecutionContext ctx)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path)) return "";
|
||||
var dot = path.IndexOf('.');
|
||||
if (dot < 0) return "";
|
||||
|
||||
var scope = path[..dot];
|
||||
var rest = path[(dot + 1)..];
|
||||
|
||||
return scope.ToLowerInvariant() switch
|
||||
{
|
||||
"body" => ResolveJson(ctx.BodyJson, rest),
|
||||
"header" => LookupCaseInsensitive(ctx.Headers, rest),
|
||||
"query" => LookupCaseInsensitive(ctx.Query, rest),
|
||||
"route" => LookupCaseInsensitive(ctx.Route, rest),
|
||||
_ => "",
|
||||
};
|
||||
}
|
||||
|
||||
private static string ResolveJson(JsonNode? root, string path)
|
||||
{
|
||||
if (root is null) return "";
|
||||
JsonNode? cursor = root;
|
||||
foreach (var segment in path.Split('.'))
|
||||
{
|
||||
if (cursor is null) return "";
|
||||
|
||||
if (cursor is JsonObject obj)
|
||||
{
|
||||
cursor = obj.TryGetPropertyValue(segment, out var next) ? next : null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (cursor is JsonArray arr && int.TryParse(segment, out var idx))
|
||||
{
|
||||
cursor = idx >= 0 && idx < arr.Count ? arr[idx] : null;
|
||||
continue;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
return cursor switch
|
||||
{
|
||||
null => "",
|
||||
JsonValue v => v.ToString(),
|
||||
_ => cursor.ToJsonString(),
|
||||
};
|
||||
}
|
||||
|
||||
private static string LookupCaseInsensitive(IReadOnlyDictionary<string, string> map, string key)
|
||||
{
|
||||
foreach (var kvp in map)
|
||||
{
|
||||
if (string.Equals(kvp.Key, key, StringComparison.OrdinalIgnoreCase))
|
||||
return kvp.Value;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace WebhookServer.Core.Execution;
|
||||
|
||||
/// <summary>
|
||||
/// Holds one <see cref="SemaphoreSlim"/> per endpoint. When an endpoint is configured
|
||||
/// with Serialize=true, the executor must acquire its semaphore before running and
|
||||
/// release after — guaranteeing at-most-one concurrent run per endpoint.
|
||||
/// </summary>
|
||||
public sealed class ConcurrencyGate
|
||||
{
|
||||
private readonly ConcurrentDictionary<Guid, SemaphoreSlim> _gates = new();
|
||||
|
||||
public async Task<IDisposable> AcquireAsync(Guid endpointId, CancellationToken ct)
|
||||
{
|
||||
var sem = _gates.GetOrAdd(endpointId, _ => new SemaphoreSlim(1, 1));
|
||||
await sem.WaitAsync(ct).ConfigureAwait(false);
|
||||
return new Releaser(sem);
|
||||
}
|
||||
|
||||
public void Forget(Guid endpointId)
|
||||
{
|
||||
if (_gates.TryRemove(endpointId, out var sem))
|
||||
sem.Dispose();
|
||||
}
|
||||
|
||||
private sealed class Releaser : IDisposable
|
||||
{
|
||||
private SemaphoreSlim? _sem;
|
||||
public Releaser(SemaphoreSlim sem) => _sem = sem;
|
||||
public void Dispose()
|
||||
{
|
||||
var sem = Interlocked.Exchange(ref _sem, null);
|
||||
sem?.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace WebhookServer.Core.Execution;
|
||||
|
||||
/// <summary>
|
||||
/// All data the executor needs from the inbound HTTP request.
|
||||
/// </summary>
|
||||
public sealed class ExecutionContext
|
||||
{
|
||||
public required string RunId { get; init; }
|
||||
public required string Slug { get; init; }
|
||||
public required byte[] BodyBytes { get; init; }
|
||||
public required string BodyString { get; init; }
|
||||
public JsonNode? BodyJson { get; init; }
|
||||
public required IReadOnlyDictionary<string, string> Headers { get; init; }
|
||||
public required IReadOnlyDictionary<string, string> Query { get; init; }
|
||||
public required IReadOnlyDictionary<string, string> Route { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace WebhookServer.Core.Execution;
|
||||
|
||||
public sealed class ExecutionResult
|
||||
{
|
||||
public required string RunId { get; init; }
|
||||
public required int ExitCode { get; init; }
|
||||
public required string Stdout { get; init; }
|
||||
public required string Stderr { get; init; }
|
||||
public bool StdoutTruncated { get; init; }
|
||||
public bool StderrTruncated { get; init; }
|
||||
public required DateTimeOffset StartedAt { get; init; }
|
||||
public required DateTimeOffset CompletedAt { get; init; }
|
||||
public required bool TimedOut { get; init; }
|
||||
public string? LaunchError { get; init; }
|
||||
|
||||
public TimeSpan Duration => CompletedAt - StartedAt;
|
||||
public bool Succeeded => !TimedOut && LaunchError is null && ExitCode == 0;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using WebhookServer.Core.Models;
|
||||
|
||||
namespace WebhookServer.Core.Execution;
|
||||
|
||||
public interface IExecutor
|
||||
{
|
||||
Task<ExecutionResult> RunAsync(EndpointConfig endpoint, ExecutionContext ctx, CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.Versioning;
|
||||
using System.Text;
|
||||
using Microsoft.Win32.SafeHandles;
|
||||
using static WebhookServer.Core.Execution.Native.NativeMethods;
|
||||
|
||||
namespace WebhookServer.Core.Execution.Native;
|
||||
|
||||
/// <summary>
|
||||
/// Launches a child process inside the active console session under whoever is
|
||||
/// logged in at the keyboard. Required when running as SYSTEM (the service account)
|
||||
/// because <c>Process.Start</c> would land the child in Session 0 where it can't
|
||||
/// show UI on the user's desktop.
|
||||
///
|
||||
/// Caller must already be SYSTEM (the service runs as SYSTEM by default) — only
|
||||
/// SYSTEM can call <c>WTSQueryUserToken</c>.
|
||||
/// </summary>
|
||||
[SupportedOSPlatform("windows")]
|
||||
internal static class InteractiveProcessLauncher
|
||||
{
|
||||
public sealed class LaunchOptions
|
||||
{
|
||||
public required string FileName { get; init; }
|
||||
public required IReadOnlyList<string> Arguments { get; init; }
|
||||
public string? WorkingDirectory { get; init; }
|
||||
public IReadOnlyDictionary<string, string>? ExtraEnvVars { get; init; }
|
||||
public byte[]? StdinBytes { get; init; }
|
||||
}
|
||||
|
||||
public sealed class LaunchResult : IDisposable
|
||||
{
|
||||
public required IntPtr ProcessHandle { get; init; }
|
||||
public required uint ProcessId { get; init; }
|
||||
public required StreamReader Stdout { get; init; }
|
||||
public required StreamReader Stderr { get; init; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Stdout.Dispose(); } catch { }
|
||||
try { Stderr.Dispose(); } catch { }
|
||||
if (ProcessHandle != IntPtr.Zero)
|
||||
try { CloseHandle(ProcessHandle); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Launch into the session of whoever is logged in at the keyboard. Lets hooks
|
||||
/// pop UI on the user's desktop. Caller must be SYSTEM (only SYSTEM can call
|
||||
/// WTSQueryUserToken).
|
||||
/// </summary>
|
||||
public static LaunchResult LaunchAsActiveConsoleUser(LaunchOptions opts)
|
||||
{
|
||||
var sessionId = WTSGetActiveConsoleSessionId();
|
||||
if (sessionId == INVALID_SESSION_ID)
|
||||
throw new InvalidOperationException("No active console session - is anyone logged in at the keyboard?");
|
||||
|
||||
if (!WTSQueryUserToken(sessionId, out var userToken))
|
||||
throw LastError("WTSQueryUserToken (must run as SYSTEM)");
|
||||
|
||||
try { return LaunchWithToken(userToken, opts, useInteractiveDesktop: true); }
|
||||
finally { CloseHandle(userToken); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Launch under a username/password by calling LogonUser to obtain a token.
|
||||
/// Used instead of psi.UserName/Password because CreateProcessWithLogonW (what
|
||||
/// .NET uses under the hood) refuses to run when the caller is SYSTEM.
|
||||
/// Tries interactive logon first, then batch.
|
||||
/// </summary>
|
||||
public static LaunchResult LaunchAsSpecificUser(string username, string password, string? domain, LaunchOptions opts)
|
||||
{
|
||||
var resolvedDomain = NormalizeDomain(domain);
|
||||
IntPtr token;
|
||||
if (!LogonUser(username, resolvedDomain, password, LOGON32_LOGON_INTERACTIVE, LOGON32_PROVIDER_DEFAULT, out token))
|
||||
{
|
||||
if (!LogonUser(username, resolvedDomain, password, LOGON32_LOGON_BATCH, LOGON32_PROVIDER_DEFAULT, out token))
|
||||
{
|
||||
var who = string.IsNullOrEmpty(domain) ? username : $"{domain}\\{username}";
|
||||
throw LastError($"LogonUser ({who})");
|
||||
}
|
||||
}
|
||||
|
||||
try { return LaunchWithToken(token, opts, useInteractiveDesktop: false); }
|
||||
finally { CloseHandle(token); }
|
||||
}
|
||||
|
||||
private static string? NormalizeDomain(string? domain)
|
||||
{
|
||||
if (string.IsNullOrEmpty(domain)) return null;
|
||||
// "." is a common shorthand for "this machine"; LogonUser wants the actual
|
||||
// machine name or null for local accounts.
|
||||
if (domain == ".") return Environment.MachineName;
|
||||
return domain;
|
||||
}
|
||||
|
||||
private static LaunchResult LaunchWithToken(IntPtr sourceToken, LaunchOptions opts, bool useInteractiveDesktop)
|
||||
{
|
||||
IntPtr primaryToken = IntPtr.Zero;
|
||||
IntPtr envBlock = IntPtr.Zero;
|
||||
IntPtr stdoutRead = IntPtr.Zero, stdoutWrite = IntPtr.Zero;
|
||||
IntPtr stderrRead = IntPtr.Zero, stderrWrite = IntPtr.Zero;
|
||||
IntPtr stdinRead = IntPtr.Zero, stdinWrite = IntPtr.Zero;
|
||||
var pi = new PROCESS_INFORMATION();
|
||||
bool succeeded = false;
|
||||
|
||||
try
|
||||
{
|
||||
if (!DuplicateTokenEx(sourceToken, (uint)MAXIMUM_ALLOWED, IntPtr.Zero,
|
||||
SECURITY_IMPERSONATION_LEVEL.SecurityImpersonation,
|
||||
TOKEN_TYPE.TokenPrimary, out primaryToken))
|
||||
throw LastError("DuplicateTokenEx");
|
||||
|
||||
if (!CreateEnvironmentBlock(out envBlock, primaryToken, false))
|
||||
throw LastError("CreateEnvironmentBlock");
|
||||
|
||||
if (opts.ExtraEnvVars is { Count: > 0 })
|
||||
envBlock = AppendEnvVars(envBlock, opts.ExtraEnvVars);
|
||||
|
||||
CreateInheritablePipe(out stdoutRead, out stdoutWrite, parentReads: true);
|
||||
CreateInheritablePipe(out stderrRead, out stderrWrite, parentReads: true);
|
||||
CreateInheritablePipe(out stdinRead, out stdinWrite, parentReads: false);
|
||||
|
||||
var si = new STARTUPINFO
|
||||
{
|
||||
cb = Marshal.SizeOf<STARTUPINFO>(),
|
||||
dwFlags = STARTF_USESTDHANDLES,
|
||||
hStdInput = stdinRead,
|
||||
hStdOutput = stdoutWrite,
|
||||
hStdError = stderrWrite,
|
||||
// For InteractiveUser we explicitly target the logged-in user's desktop.
|
||||
// For SpecificUser the LogonUser-derived token typically can't open that
|
||||
// DACL; leave lpDesktop null and let the new process inherit ours.
|
||||
lpDesktop = useInteractiveDesktop ? @"winsta0\default" : null,
|
||||
};
|
||||
|
||||
var commandLine = BuildCommandLine(opts.FileName, opts.Arguments);
|
||||
|
||||
if (!CreateProcessAsUser(
|
||||
primaryToken,
|
||||
null,
|
||||
commandLine,
|
||||
IntPtr.Zero, IntPtr.Zero,
|
||||
bInheritHandles: true,
|
||||
CREATE_UNICODE_ENVIRONMENT | CREATE_NO_WINDOW | NORMAL_PRIORITY_CLASS,
|
||||
envBlock,
|
||||
string.IsNullOrEmpty(opts.WorkingDirectory) ? null : opts.WorkingDirectory,
|
||||
ref si,
|
||||
out pi))
|
||||
{
|
||||
throw LastError("CreateProcessAsUser");
|
||||
}
|
||||
|
||||
// Close child-side handles in our process so EOF propagates when the child exits.
|
||||
CloseHandle(stdoutWrite); stdoutWrite = IntPtr.Zero;
|
||||
CloseHandle(stderrWrite); stderrWrite = IntPtr.Zero;
|
||||
CloseHandle(stdinRead); stdinRead = IntPtr.Zero;
|
||||
|
||||
// Pipe stdin if provided, then close the write end.
|
||||
if (opts.StdinBytes is { Length: > 0 })
|
||||
{
|
||||
using var stdinStream = new FileStream(new SafeFileHandle(stdinWrite, ownsHandle: true), FileAccess.Write);
|
||||
stdinStream.Write(opts.StdinBytes, 0, opts.StdinBytes.Length);
|
||||
stdinStream.Flush();
|
||||
stdinWrite = IntPtr.Zero; // ownership transferred to the FileStream
|
||||
}
|
||||
else
|
||||
{
|
||||
CloseHandle(stdinWrite); stdinWrite = IntPtr.Zero;
|
||||
}
|
||||
|
||||
CloseHandle(pi.hThread);
|
||||
|
||||
var stdout = new StreamReader(new FileStream(new SafeFileHandle(stdoutRead, true), FileAccess.Read), Encoding.UTF8);
|
||||
var stderr = new StreamReader(new FileStream(new SafeFileHandle(stderrRead, true), FileAccess.Read), Encoding.UTF8);
|
||||
stdoutRead = IntPtr.Zero;
|
||||
stderrRead = IntPtr.Zero;
|
||||
|
||||
succeeded = true;
|
||||
return new LaunchResult
|
||||
{
|
||||
ProcessHandle = pi.hProcess,
|
||||
ProcessId = pi.dwProcessId,
|
||||
Stdout = stdout,
|
||||
Stderr = stderr,
|
||||
};
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (primaryToken != IntPtr.Zero) CloseHandle(primaryToken);
|
||||
if (envBlock != IntPtr.Zero) DestroyEnvironmentBlock(envBlock);
|
||||
if (!succeeded)
|
||||
{
|
||||
if (stdoutRead != IntPtr.Zero) CloseHandle(stdoutRead);
|
||||
if (stdoutWrite != IntPtr.Zero) CloseHandle(stdoutWrite);
|
||||
if (stderrRead != IntPtr.Zero) CloseHandle(stderrRead);
|
||||
if (stderrWrite != IntPtr.Zero) CloseHandle(stderrWrite);
|
||||
if (stdinRead != IntPtr.Zero) CloseHandle(stdinRead);
|
||||
if (stdinWrite != IntPtr.Zero) CloseHandle(stdinWrite);
|
||||
if (pi.hProcess != IntPtr.Zero) CloseHandle(pi.hProcess);
|
||||
if (pi.hThread != IntPtr.Zero) CloseHandle(pi.hThread);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task<int> WaitAsync(IntPtr processHandle, TimeSpan timeout, CancellationToken ct)
|
||||
{
|
||||
// Poll WaitForSingleObject in 100ms slices to honor cancellation/timeout cheaply.
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (true)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var remaining = deadline - DateTime.UtcNow;
|
||||
if (remaining <= TimeSpan.Zero) throw new TimeoutException();
|
||||
|
||||
var slice = (uint)Math.Min(100, (int)remaining.TotalMilliseconds);
|
||||
var result = WaitForSingleObject(processHandle, slice);
|
||||
if (result == 0) // WAIT_OBJECT_0
|
||||
{
|
||||
if (!GetExitCodeProcess(processHandle, out var code))
|
||||
throw LastError("GetExitCodeProcess");
|
||||
return (int)code;
|
||||
}
|
||||
if (result == 0xFFFFFFFF)
|
||||
throw LastError("WaitForSingleObject");
|
||||
await Task.Yield();
|
||||
}
|
||||
}
|
||||
|
||||
public static void Kill(IntPtr processHandle)
|
||||
{
|
||||
try { TerminateProcess(processHandle, 1); } catch { }
|
||||
}
|
||||
|
||||
private static void CreateInheritablePipe(out IntPtr read, out IntPtr write, bool parentReads)
|
||||
{
|
||||
var sa = new SECURITY_ATTRIBUTES
|
||||
{
|
||||
nLength = Marshal.SizeOf<SECURITY_ATTRIBUTES>(),
|
||||
bInheritHandle = 1,
|
||||
lpSecurityDescriptor = IntPtr.Zero,
|
||||
};
|
||||
if (!CreatePipe(out read, out write, ref sa, 0))
|
||||
throw LastError("CreatePipe");
|
||||
|
||||
// Mark the parent's end non-inheritable so it's not duplicated into the child.
|
||||
var parentEnd = parentReads ? read : write;
|
||||
if (!SetHandleInformation(parentEnd, HANDLE_FLAG_INHERIT, 0))
|
||||
throw LastError("SetHandleInformation");
|
||||
}
|
||||
|
||||
private static IntPtr AppendEnvVars(IntPtr existingBlock, IReadOnlyDictionary<string, string> extras)
|
||||
{
|
||||
// The existing block is a sequence of null-terminated WCHAR strings ending with
|
||||
// an extra null. Walk it byte-pair by byte-pair to find the end.
|
||||
var parsed = new List<string>();
|
||||
var offset = 0;
|
||||
while (true)
|
||||
{
|
||||
var ch0 = Marshal.ReadInt16(existingBlock, offset);
|
||||
if (ch0 == 0) break;
|
||||
var entry = Marshal.PtrToStringUni(existingBlock + offset)!;
|
||||
parsed.Add(entry);
|
||||
offset += (entry.Length + 1) * sizeof(char);
|
||||
}
|
||||
DestroyEnvironmentBlock(existingBlock);
|
||||
|
||||
var combined = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var entry in parsed)
|
||||
{
|
||||
var eq = entry.IndexOf('=');
|
||||
if (eq <= 0) continue;
|
||||
combined[entry.Substring(0, eq)] = entry.Substring(eq + 1);
|
||||
}
|
||||
foreach (var (k, v) in extras) combined[k] = v;
|
||||
|
||||
var sb = new StringBuilder();
|
||||
foreach (var (k, v) in combined)
|
||||
{
|
||||
sb.Append(k).Append('=').Append(v).Append('\0');
|
||||
}
|
||||
sb.Append('\0');
|
||||
|
||||
var bytes = Encoding.Unicode.GetBytes(sb.ToString());
|
||||
var ptr = Marshal.AllocHGlobal(bytes.Length);
|
||||
Marshal.Copy(bytes, 0, ptr, bytes.Length);
|
||||
return ptr;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build a Windows command line string from a filename + arg list using the
|
||||
/// quoting rules consumed by CommandLineToArgvW.
|
||||
/// </summary>
|
||||
private static string BuildCommandLine(string fileName, IReadOnlyList<string> args)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
AppendArg(sb, fileName);
|
||||
foreach (var arg in args)
|
||||
{
|
||||
sb.Append(' ');
|
||||
AppendArg(sb, arg);
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static void AppendArg(StringBuilder sb, string arg)
|
||||
{
|
||||
// Empty arg → "".
|
||||
if (arg.Length == 0) { sb.Append("\"\""); return; }
|
||||
|
||||
var needsQuoting = arg.IndexOfAny(new[] { ' ', '\t', '\n', '\v', '"' }) >= 0;
|
||||
if (!needsQuoting) { sb.Append(arg); return; }
|
||||
|
||||
sb.Append('"');
|
||||
var backslashes = 0;
|
||||
foreach (var ch in arg)
|
||||
{
|
||||
if (ch == '\\') { backslashes++; continue; }
|
||||
if (ch == '"')
|
||||
{
|
||||
sb.Append('\\', backslashes * 2 + 1);
|
||||
sb.Append('"');
|
||||
backslashes = 0;
|
||||
continue;
|
||||
}
|
||||
if (backslashes > 0) { sb.Append('\\', backslashes); backslashes = 0; }
|
||||
sb.Append(ch);
|
||||
}
|
||||
// Trailing backslashes before closing quote must be doubled.
|
||||
if (backslashes > 0) sb.Append('\\', backslashes * 2);
|
||||
sb.Append('"');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.Versioning;
|
||||
|
||||
namespace WebhookServer.Core.Execution.Native;
|
||||
|
||||
/// <summary>
|
||||
/// Win32 P/Invoke layer for launching processes in another user's session.
|
||||
/// Used by <see cref="InteractiveProcessLauncher"/>; not intended for general use.
|
||||
/// </summary>
|
||||
[SupportedOSPlatform("windows")]
|
||||
internal static class NativeMethods
|
||||
{
|
||||
public const uint INVALID_SESSION_ID = 0xFFFFFFFF;
|
||||
public const int MAXIMUM_ALLOWED = 0x02000000;
|
||||
|
||||
public const uint CREATE_UNICODE_ENVIRONMENT = 0x00000400;
|
||||
public const uint CREATE_NO_WINDOW = 0x08000000;
|
||||
public const uint CREATE_NEW_CONSOLE = 0x00000010;
|
||||
public const uint NORMAL_PRIORITY_CLASS = 0x00000020;
|
||||
|
||||
public const int STARTF_USESTDHANDLES = 0x00000100;
|
||||
|
||||
public const int HANDLE_FLAG_INHERIT = 1;
|
||||
|
||||
public const uint INFINITE = 0xFFFFFFFF;
|
||||
|
||||
public enum SECURITY_IMPERSONATION_LEVEL
|
||||
{
|
||||
SecurityAnonymous = 0,
|
||||
SecurityIdentification = 1,
|
||||
SecurityImpersonation = 2,
|
||||
SecurityDelegation = 3,
|
||||
}
|
||||
|
||||
public enum TOKEN_TYPE
|
||||
{
|
||||
TokenPrimary = 1,
|
||||
TokenImpersonation = 2,
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct SECURITY_ATTRIBUTES
|
||||
{
|
||||
public int nLength;
|
||||
public IntPtr lpSecurityDescriptor;
|
||||
public int bInheritHandle;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||
public struct STARTUPINFO
|
||||
{
|
||||
public int cb;
|
||||
public string? lpReserved;
|
||||
public string? lpDesktop;
|
||||
public string? lpTitle;
|
||||
public uint dwX;
|
||||
public uint dwY;
|
||||
public uint dwXSize;
|
||||
public uint dwYSize;
|
||||
public uint dwXCountChars;
|
||||
public uint dwYCountChars;
|
||||
public uint dwFillAttribute;
|
||||
public uint dwFlags;
|
||||
public ushort wShowWindow;
|
||||
public ushort cbReserved2;
|
||||
public IntPtr lpReserved2;
|
||||
public IntPtr hStdInput;
|
||||
public IntPtr hStdOutput;
|
||||
public IntPtr hStdError;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct PROCESS_INFORMATION
|
||||
{
|
||||
public IntPtr hProcess;
|
||||
public IntPtr hThread;
|
||||
public uint dwProcessId;
|
||||
public uint dwThreadId;
|
||||
}
|
||||
|
||||
public const int LOGON32_LOGON_INTERACTIVE = 2;
|
||||
public const int LOGON32_LOGON_BATCH = 4;
|
||||
public const int LOGON32_PROVIDER_DEFAULT = 0;
|
||||
|
||||
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
public static extern bool LogonUser(
|
||||
string lpszUsername,
|
||||
string? lpszDomain,
|
||||
string lpszPassword,
|
||||
int dwLogonType,
|
||||
int dwLogonProvider,
|
||||
out IntPtr phToken);
|
||||
|
||||
[DllImport("kernel32.dll")]
|
||||
public static extern uint WTSGetActiveConsoleSessionId();
|
||||
|
||||
[DllImport("wtsapi32.dll", SetLastError = true)]
|
||||
public static extern bool WTSQueryUserToken(uint sessionId, out IntPtr phToken);
|
||||
|
||||
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
public static extern bool DuplicateTokenEx(
|
||||
IntPtr hExistingToken,
|
||||
uint dwDesiredAccess,
|
||||
IntPtr lpTokenAttributes,
|
||||
SECURITY_IMPERSONATION_LEVEL impersonationLevel,
|
||||
TOKEN_TYPE tokenType,
|
||||
out IntPtr phNewToken);
|
||||
|
||||
[DllImport("userenv.dll", SetLastError = true)]
|
||||
public static extern bool CreateEnvironmentBlock(
|
||||
out IntPtr lpEnvironment,
|
||||
IntPtr hToken,
|
||||
bool bInherit);
|
||||
|
||||
[DllImport("userenv.dll", SetLastError = true)]
|
||||
public static extern bool DestroyEnvironmentBlock(IntPtr lpEnvironment);
|
||||
|
||||
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
public static extern bool CreateProcessAsUser(
|
||||
IntPtr hToken,
|
||||
string? lpApplicationName,
|
||||
string? lpCommandLine,
|
||||
IntPtr lpProcessAttributes,
|
||||
IntPtr lpThreadAttributes,
|
||||
bool bInheritHandles,
|
||||
uint dwCreationFlags,
|
||||
IntPtr lpEnvironment,
|
||||
string? lpCurrentDirectory,
|
||||
ref STARTUPINFO lpStartupInfo,
|
||||
out PROCESS_INFORMATION lpProcessInformation);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
public static extern bool CreatePipe(
|
||||
out IntPtr hReadPipe,
|
||||
out IntPtr hWritePipe,
|
||||
ref SECURITY_ATTRIBUTES lpPipeAttributes,
|
||||
uint nSize);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
public static extern bool SetHandleInformation(IntPtr hObject, int dwMask, int dwFlags);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
public static extern bool CloseHandle(IntPtr hObject);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
public static extern bool GetExitCodeProcess(IntPtr hProcess, out uint lpExitCode);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
public static extern uint WaitForSingleObject(IntPtr hHandle, uint dwMilliseconds);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
public static extern bool TerminateProcess(IntPtr hProcess, uint uExitCode);
|
||||
|
||||
public static Win32Exception LastError(string what) =>
|
||||
new(Marshal.GetLastWin32Error(), $"{what} failed");
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.Versioning;
|
||||
using System.Text;
|
||||
using WebhookServer.Core.Execution.Native;
|
||||
using WebhookServer.Core.Models;
|
||||
|
||||
namespace WebhookServer.Core.Execution;
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
public sealed class ProcessExecutor : IExecutor
|
||||
{
|
||||
/// <summary>Per-stream cap on captured output (excess is dropped and StdoutTruncated set).</summary>
|
||||
public const int MaxOutputBytes = 1 * 1024 * 1024;
|
||||
|
||||
public async Task<ExecutionResult> RunAsync(EndpointConfig endpoint, ExecutionContext ctx, CancellationToken ct)
|
||||
{
|
||||
var startedAt = DateTimeOffset.UtcNow;
|
||||
var mode = endpoint.RunAs?.Mode ?? RunAsMode.Service;
|
||||
return mode switch
|
||||
{
|
||||
RunAsMode.InteractiveUser => await RunWithLauncherAsync(endpoint, ctx, startedAt, useActiveConsole: true, ct).ConfigureAwait(false),
|
||||
RunAsMode.SpecificUser => await RunWithLauncherAsync(endpoint, ctx, startedAt, useActiveConsole: false, ct).ConfigureAwait(false),
|
||||
_ => await RunWithProcessAsync(endpoint, ctx, startedAt, ct).ConfigureAwait(false),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------- Process path: handles Service (default) and SpecificUser. ----------------
|
||||
|
||||
private async Task<ExecutionResult> RunWithProcessAsync(EndpointConfig endpoint, ExecutionContext ctx, DateTimeOffset startedAt, CancellationToken ct)
|
||||
{
|
||||
var (psi, envVars) = BuildStartInfo(endpoint, ctx);
|
||||
foreach (var (k, v) in envVars)
|
||||
psi.Environment[k] = v;
|
||||
|
||||
using var process = new Process { StartInfo = psi, EnableRaisingEvents = true };
|
||||
|
||||
try
|
||||
{
|
||||
if (!process.Start())
|
||||
return Failed(ctx.RunId, startedAt, "process failed to start");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Failed(ctx.RunId, startedAt, $"launch error: {ex.Message}");
|
||||
}
|
||||
|
||||
if (endpoint.DataPassing.StdinJson)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (ctx.BodyBytes.Length > 0)
|
||||
await process.StandardInput.BaseStream.WriteAsync(ctx.BodyBytes, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Failed(ctx.RunId, startedAt, $"stdin write failed: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { process.StandardInput.Close(); } catch { }
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
try { process.StandardInput.Close(); } catch { }
|
||||
}
|
||||
|
||||
var stdoutTask = ReadCappedAsync(process.StandardOutput, ct);
|
||||
var stderrTask = ReadCappedAsync(process.StandardError, ct);
|
||||
|
||||
var timeout = TimeSpan.FromSeconds(Math.Max(1, endpoint.TimeoutSeconds));
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeoutCts.CancelAfter(timeout);
|
||||
|
||||
bool timedOut = false;
|
||||
try
|
||||
{
|
||||
await process.WaitForExitAsync(timeoutCts.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
timedOut = true;
|
||||
try { process.Kill(entireProcessTree: true); } catch { }
|
||||
try { await process.WaitForExitAsync(CancellationToken.None).ConfigureAwait(false); } catch { }
|
||||
}
|
||||
|
||||
var (stdout, stdoutTrunc) = await stdoutTask.ConfigureAwait(false);
|
||||
var (stderr, stderrTrunc) = await stderrTask.ConfigureAwait(false);
|
||||
|
||||
return new ExecutionResult
|
||||
{
|
||||
RunId = ctx.RunId,
|
||||
ExitCode = timedOut ? -1 : process.ExitCode,
|
||||
Stdout = stdout,
|
||||
Stderr = stderr,
|
||||
StdoutTruncated = stdoutTrunc,
|
||||
StderrTruncated = stderrTrunc,
|
||||
StartedAt = startedAt,
|
||||
CompletedAt = DateTimeOffset.UtcNow,
|
||||
TimedOut = timedOut,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------- Token-based path: InteractiveUser + SpecificUser. ----------------
|
||||
|
||||
private static async Task<ExecutionResult> RunWithLauncherAsync(EndpointConfig endpoint, ExecutionContext ctx, DateTimeOffset startedAt, bool useActiveConsole, CancellationToken ct)
|
||||
{
|
||||
var (psi, envVars) = BuildStartInfo(endpoint, ctx);
|
||||
var opts = new InteractiveProcessLauncher.LaunchOptions
|
||||
{
|
||||
FileName = psi.FileName,
|
||||
Arguments = psi.ArgumentList.ToList(),
|
||||
WorkingDirectory = string.IsNullOrEmpty(psi.WorkingDirectory) ? null : psi.WorkingDirectory,
|
||||
ExtraEnvVars = envVars,
|
||||
StdinBytes = endpoint.DataPassing.StdinJson ? ctx.BodyBytes : null,
|
||||
};
|
||||
|
||||
InteractiveProcessLauncher.LaunchResult launch;
|
||||
try
|
||||
{
|
||||
if (useActiveConsole)
|
||||
{
|
||||
launch = InteractiveProcessLauncher.LaunchAsActiveConsoleUser(opts);
|
||||
}
|
||||
else
|
||||
{
|
||||
var runAs = endpoint.RunAs ?? throw new InvalidOperationException("RunAs config missing");
|
||||
if (string.IsNullOrEmpty(runAs.Username))
|
||||
return Failed(ctx.RunId, startedAt, "RunAs.Username is required when Mode = SpecificUser");
|
||||
if (runAs.Password?.Plaintext is not { Length: > 0 } password)
|
||||
return Failed(ctx.RunId, startedAt, "RunAs.Password is required when Mode = SpecificUser");
|
||||
|
||||
var (domain, user) = ParseUserSpec(runAs.Username);
|
||||
launch = InteractiveProcessLauncher.LaunchAsSpecificUser(user, password, domain, opts);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Failed(ctx.RunId, startedAt, $"launch error: {ex.Message}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var stdoutTask = ReadCappedAsync(launch.Stdout, ct);
|
||||
var stderrTask = ReadCappedAsync(launch.Stderr, ct);
|
||||
|
||||
var timeout = TimeSpan.FromSeconds(Math.Max(1, endpoint.TimeoutSeconds));
|
||||
bool timedOut = false;
|
||||
int exitCode = -1;
|
||||
try
|
||||
{
|
||||
exitCode = await InteractiveProcessLauncher.WaitAsync(launch.ProcessHandle, timeout, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
timedOut = true;
|
||||
InteractiveProcessLauncher.Kill(launch.ProcessHandle);
|
||||
}
|
||||
|
||||
var (stdout, stdoutTrunc) = await stdoutTask.ConfigureAwait(false);
|
||||
var (stderr, stderrTrunc) = await stderrTask.ConfigureAwait(false);
|
||||
|
||||
return new ExecutionResult
|
||||
{
|
||||
RunId = ctx.RunId,
|
||||
ExitCode = timedOut ? -1 : exitCode,
|
||||
Stdout = stdout,
|
||||
Stderr = stderr,
|
||||
StdoutTruncated = stdoutTrunc,
|
||||
StderrTruncated = stderrTrunc,
|
||||
StartedAt = startedAt,
|
||||
CompletedAt = DateTimeOffset.UtcNow,
|
||||
TimedOut = timedOut,
|
||||
};
|
||||
}
|
||||
finally
|
||||
{
|
||||
launch.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- Shared psi construction. ----------------
|
||||
|
||||
private static (ProcessStartInfo psi, Dictionary<string, string> envVars) BuildStartInfo(EndpointConfig endpoint, ExecutionContext ctx)
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
RedirectStandardInput = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
WorkingDirectory = string.IsNullOrEmpty(endpoint.WorkingDirectory)
|
||||
? Environment.CurrentDirectory
|
||||
: endpoint.WorkingDirectory!,
|
||||
};
|
||||
|
||||
switch (endpoint.ExecutorType)
|
||||
{
|
||||
case ExecutorType.WindowsPowerShell:
|
||||
psi.FileName = "powershell.exe";
|
||||
AddPwshArgs(psi, endpoint);
|
||||
break;
|
||||
case ExecutorType.PwshCore:
|
||||
psi.FileName = "pwsh.exe";
|
||||
AddPwshArgs(psi, endpoint);
|
||||
break;
|
||||
case ExecutorType.Cmd:
|
||||
psi.FileName = "cmd.exe";
|
||||
psi.ArgumentList.Add("/c");
|
||||
psi.ArgumentList.Add(ResolveCmdInvocation(endpoint));
|
||||
break;
|
||||
case ExecutorType.Executable:
|
||||
psi.FileName = endpoint.ExecutablePath ?? "";
|
||||
foreach (var staticArg in endpoint.ExecutableArgs)
|
||||
psi.ArgumentList.Add(staticArg);
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(endpoint.ExecutorType));
|
||||
}
|
||||
|
||||
if (endpoint.DataPassing.ArgTemplate)
|
||||
{
|
||||
foreach (var arg in ArgTemplateRenderer.Render(endpoint.DataPassing.ArgTemplateString, ctx))
|
||||
psi.ArgumentList.Add(arg);
|
||||
}
|
||||
|
||||
var envVars = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["WEBHOOK_RUN_ID"] = ctx.RunId,
|
||||
["WEBHOOK_SLUG"] = ctx.Slug,
|
||||
};
|
||||
|
||||
if (endpoint.DataPassing.EnvVars)
|
||||
{
|
||||
foreach (var (k, v) in ctx.Headers) envVars[$"WEBHOOK_HEADER_{Sanitize(k)}"] = v;
|
||||
foreach (var (k, v) in ctx.Query) envVars[$"WEBHOOK_QUERY_{Sanitize(k)}"] = v;
|
||||
}
|
||||
|
||||
return (psi, envVars);
|
||||
}
|
||||
|
||||
private static void AddPwshArgs(ProcessStartInfo psi, EndpointConfig endpoint)
|
||||
{
|
||||
psi.ArgumentList.Add("-NoProfile");
|
||||
psi.ArgumentList.Add("-NonInteractive");
|
||||
psi.ArgumentList.Add("-ExecutionPolicy");
|
||||
psi.ArgumentList.Add("Bypass");
|
||||
|
||||
if (!string.IsNullOrEmpty(endpoint.ScriptPath))
|
||||
{
|
||||
psi.ArgumentList.Add("-File");
|
||||
psi.ArgumentList.Add(endpoint.ScriptPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
psi.ArgumentList.Add("-Command");
|
||||
// Pipe stdin into a scriptblock so trailing argv entries bind via @args
|
||||
// and the script can still consume the request body via $input.
|
||||
// Without the wrapper, PowerShell concatenates all trailing args into the
|
||||
// -Command string and fails to parse them.
|
||||
psi.ArgumentList.Add("$input | & { " + (endpoint.InlineCommand ?? "") + " } @args");
|
||||
}
|
||||
}
|
||||
|
||||
private static string ResolveCmdInvocation(EndpointConfig endpoint)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(endpoint.ScriptPath))
|
||||
return endpoint.ScriptPath!;
|
||||
return endpoint.InlineCommand ?? "";
|
||||
}
|
||||
|
||||
private static (string Domain, string User) ParseUserSpec(string spec)
|
||||
{
|
||||
var bs = spec.IndexOf('\\');
|
||||
if (bs > 0) return (spec.Substring(0, bs), spec.Substring(bs + 1));
|
||||
return ("", spec);
|
||||
}
|
||||
|
||||
private static string Sanitize(string key)
|
||||
{
|
||||
var sb = new StringBuilder(key.Length);
|
||||
foreach (var ch in key)
|
||||
{
|
||||
if (char.IsLetterOrDigit(ch) || ch == '_')
|
||||
sb.Append(char.ToUpperInvariant(ch));
|
||||
else
|
||||
sb.Append('_');
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static async Task<(string Text, bool Truncated)> ReadCappedAsync(StreamReader reader, CancellationToken ct)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
var buffer = new char[4096];
|
||||
bool truncated = false;
|
||||
var byteEstimate = 0;
|
||||
|
||||
while (true)
|
||||
{
|
||||
int n;
|
||||
try { n = await reader.ReadAsync(buffer, ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { break; }
|
||||
catch (IOException) { break; }
|
||||
if (n == 0) break;
|
||||
|
||||
if (!truncated)
|
||||
{
|
||||
if (byteEstimate + n > MaxOutputBytes)
|
||||
{
|
||||
var allowed = MaxOutputBytes - byteEstimate;
|
||||
if (allowed > 0) sb.Append(buffer, 0, allowed);
|
||||
truncated = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(buffer, 0, n);
|
||||
byteEstimate += n;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (sb.ToString(), truncated);
|
||||
}
|
||||
|
||||
private static ExecutionResult Failed(string runId, DateTimeOffset startedAt, string reason) => new()
|
||||
{
|
||||
RunId = runId,
|
||||
ExitCode = -1,
|
||||
Stdout = "",
|
||||
Stderr = "",
|
||||
StartedAt = startedAt,
|
||||
CompletedAt = DateTimeOffset.UtcNow,
|
||||
TimedOut = false,
|
||||
LaunchError = reason,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace WebhookServer.Core.Ipc;
|
||||
|
||||
/// <summary>
|
||||
/// Operation discriminators for the named-pipe admin protocol. Request payload shape
|
||||
/// is op-specific; the handler is responsible for binding <see cref="AdminRequest.Data"/>
|
||||
/// to the right concrete type.
|
||||
/// </summary>
|
||||
public static class AdminOps
|
||||
{
|
||||
public const string GetConfig = "get-config";
|
||||
public const string UpdateConfig = "update-config";
|
||||
public const string ListEndpoints = "list-endpoints";
|
||||
public const string CreateEndpoint = "create-endpoint";
|
||||
public const string UpdateEndpoint = "update-endpoint";
|
||||
public const string DeleteEndpoint = "delete-endpoint";
|
||||
public const string EnableEndpoint = "enable-endpoint";
|
||||
public const string DisableEndpoint = "disable-endpoint";
|
||||
public const string GetStatus = "get-status";
|
||||
public const string TailLogs = "tail-logs";
|
||||
public const string BindHttps = "bind-https";
|
||||
public const string RestartListener = "restart-listener";
|
||||
public const string Ping = "ping";
|
||||
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
|
||||
{
|
||||
public string FileName { get; set; } = "";
|
||||
public DateTimeOffset SavedAt { get; set; }
|
||||
public long SizeBytes { get; set; }
|
||||
public string? Description { get; set; }
|
||||
}
|
||||
|
||||
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; } = "";
|
||||
[JsonPropertyName("data")] public JsonElement? Data { get; set; }
|
||||
}
|
||||
|
||||
public sealed class AdminResponse
|
||||
{
|
||||
[JsonPropertyName("ok")] public bool Ok { get; set; }
|
||||
[JsonPropertyName("error")] public string? Error { get; set; }
|
||||
[JsonPropertyName("data")] public JsonElement? Data { get; set; }
|
||||
|
||||
public static AdminResponse Success(object? payload = null)
|
||||
{
|
||||
if (payload is null) return new AdminResponse { Ok = true };
|
||||
var doc = JsonSerializer.SerializeToDocument(payload, AdminProtocol.JsonOptions);
|
||||
return new AdminResponse { Ok = true, Data = doc.RootElement.Clone() };
|
||||
}
|
||||
|
||||
public static AdminResponse Failure(string error) => new() { Ok = false, Error = error };
|
||||
}
|
||||
|
||||
public sealed class StatusInfo
|
||||
{
|
||||
public bool Running { get; set; }
|
||||
public int HttpPort { get; set; }
|
||||
public int? HttpsPort { get; set; }
|
||||
public string? DisplayHost { get; set; }
|
||||
public DateTimeOffset StartedAt { get; set; }
|
||||
public int EndpointCount { get; set; }
|
||||
}
|
||||
|
||||
public sealed class EndpointToggle
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
}
|
||||
|
||||
public sealed class DeleteEndpointArgs
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
}
|
||||
|
||||
public sealed class TailLogsArgs
|
||||
{
|
||||
public int LinesToBacklog { get; set; } = 100;
|
||||
public bool Follow { get; set; } = true;
|
||||
}
|
||||
|
||||
public sealed class LogLine
|
||||
{
|
||||
public DateTimeOffset Timestamp { get; set; }
|
||||
public string Level { get; set; } = "Information";
|
||||
public string Message { get; set; } = "";
|
||||
}
|
||||
|
||||
public static class AdminProtocol
|
||||
{
|
||||
public static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false,
|
||||
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace WebhookServer.Core.Ipc;
|
||||
|
||||
/// <summary>
|
||||
/// Line-delimited JSON over a stream. One JSON object per line, terminated by '\n'.
|
||||
/// </summary>
|
||||
public static class PipeFraming
|
||||
{
|
||||
public static async Task WriteAsync<T>(Stream stream, T payload, CancellationToken ct)
|
||||
{
|
||||
var bytes = JsonSerializer.SerializeToUtf8Bytes(payload, AdminProtocol.JsonOptions);
|
||||
await stream.WriteAsync(bytes, ct).ConfigureAwait(false);
|
||||
await stream.WriteAsync(new byte[] { (byte)'\n' }, ct).ConfigureAwait(false);
|
||||
await stream.FlushAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public static async Task<T?> ReadAsync<T>(StreamReader reader, CancellationToken ct)
|
||||
{
|
||||
var line = await reader.ReadLineAsync(ct).ConfigureAwait(false);
|
||||
if (line is null) return default;
|
||||
if (string.IsNullOrWhiteSpace(line)) return default;
|
||||
return JsonSerializer.Deserialize<T>(line, AdminProtocol.JsonOptions);
|
||||
}
|
||||
|
||||
public static StreamReader CreateReader(Stream stream) =>
|
||||
new(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, bufferSize: 4096, leaveOpen: true);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using System.IO.Pipes;
|
||||
using System.Runtime.Versioning;
|
||||
using System.Security.AccessControl;
|
||||
using System.Security.Principal;
|
||||
|
||||
namespace WebhookServer.Core.Ipc;
|
||||
|
||||
/// <summary>
|
||||
/// Builds a <see cref="PipeSecurity"/> that allows SYSTEM and the local Administrators
|
||||
/// group full control, and denies everyone else. Required so non-admin users cannot
|
||||
/// read or write the admin pipe even if they know the name.
|
||||
/// </summary>
|
||||
[SupportedOSPlatform("windows")]
|
||||
public static class PipeSecurityFactory
|
||||
{
|
||||
public const string PipeName = "WebhookServerAdmin";
|
||||
|
||||
public static PipeSecurity Create()
|
||||
{
|
||||
var security = new PipeSecurity();
|
||||
|
||||
var system = new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null);
|
||||
var administrators = new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null);
|
||||
|
||||
security.AddAccessRule(new PipeAccessRule(
|
||||
system, PipeAccessRights.FullControl, AccessControlType.Allow));
|
||||
|
||||
security.AddAccessRule(new PipeAccessRule(
|
||||
administrators, PipeAccessRights.FullControl, AccessControlType.Allow));
|
||||
|
||||
return security;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace WebhookServer.Core.Models;
|
||||
|
||||
public sealed class BearerOptions
|
||||
{
|
||||
public ProtectedString Secret { get; set; } = new();
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace WebhookServer.Core.Models;
|
||||
|
||||
public sealed class CallbackConfig
|
||||
{
|
||||
public string Url { get; set; } = "";
|
||||
public CallbackHttpMethod Method { get; set; } = CallbackHttpMethod.Post;
|
||||
public AuthMode AuthMode { get; set; } = AuthMode.None;
|
||||
public BearerOptions? Bearer { get; set; }
|
||||
public HmacOptions? Hmac { get; set; }
|
||||
public int TimeoutSeconds { get; set; } = 30;
|
||||
public int MaxAttempts { get; set; } = 5;
|
||||
public bool IncludeStdout { get; set; } = true;
|
||||
public bool IncludeStderr { get; set; } = true;
|
||||
public int MaxOutputBytes { get; set; } = 64 * 1024;
|
||||
public CallbackTrigger Trigger { get; set; } = CallbackTrigger.OnComplete;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace WebhookServer.Core.Models;
|
||||
|
||||
public sealed class DataPassingOptions
|
||||
{
|
||||
public bool StdinJson { get; set; }
|
||||
public bool EnvVars { get; set; }
|
||||
public bool ArgTemplate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whitespace-separated list of template tokens; each rendered token becomes one argv entry.
|
||||
/// Only used when <see cref="ArgTemplate"/> is true.
|
||||
/// </summary>
|
||||
public string? ArgTemplateString { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
namespace WebhookServer.Core.Models;
|
||||
|
||||
public sealed class EndpointConfig
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public string Slug { get; set; } = "";
|
||||
public string? Description { get; set; }
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
public List<string> AllowedClients { get; set; } = new();
|
||||
|
||||
public AuthMode AuthMode { get; set; } = AuthMode.None;
|
||||
public BearerOptions? Bearer { get; set; }
|
||||
public HmacOptions? Hmac { get; set; }
|
||||
|
||||
public ExecutorType ExecutorType { get; set; } = ExecutorType.WindowsPowerShell;
|
||||
|
||||
/// <summary>Path to a script file (.ps1, .bat, .cmd) when applicable.</summary>
|
||||
public string? ScriptPath { get; set; }
|
||||
|
||||
/// <summary>Inline command body when no script file is used (PowerShell -Command, cmd /c).</summary>
|
||||
public string? InlineCommand { get; set; }
|
||||
|
||||
/// <summary>Path to the executable when ExecutorType = Executable.</summary>
|
||||
public string? ExecutablePath { get; set; }
|
||||
|
||||
/// <summary>Static argv prefix for Executable mode; the rendered ArgTemplate appends after.</summary>
|
||||
public List<string> ExecutableArgs { get; set; } = new();
|
||||
|
||||
public string? WorkingDirectory { get; set; }
|
||||
|
||||
public DataPassingOptions DataPassing { get; set; } = new();
|
||||
|
||||
public ResponseMode ResponseMode { get; set; } = ResponseMode.Sync;
|
||||
|
||||
public int TimeoutSeconds { get; set; } = 60;
|
||||
|
||||
/// <summary>If true, a non-zero process exit produces 502 in sync mode (default true).</summary>
|
||||
public bool FailOnNonZeroExit { get; set; } = true;
|
||||
|
||||
/// <summary>If true, requests are processed one at a time per endpoint.</summary>
|
||||
public bool Serialize { get; set; }
|
||||
|
||||
public CallbackConfig? Callback { get; set; }
|
||||
|
||||
public RunAsConfig? RunAs { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
namespace WebhookServer.Core.Models;
|
||||
|
||||
public enum AuthMode
|
||||
{
|
||||
None = 0,
|
||||
Bearer = 1,
|
||||
Hmac = 2,
|
||||
}
|
||||
|
||||
public enum HmacAlgorithm
|
||||
{
|
||||
Sha1 = 1,
|
||||
Sha256 = 2,
|
||||
Sha512 = 3,
|
||||
}
|
||||
|
||||
public enum HmacEncoding
|
||||
{
|
||||
Hex = 0,
|
||||
Base64 = 1,
|
||||
}
|
||||
|
||||
public enum ExecutorType
|
||||
{
|
||||
WindowsPowerShell = 0,
|
||||
PwshCore = 1,
|
||||
Cmd = 2,
|
||||
Executable = 3,
|
||||
}
|
||||
|
||||
public enum ResponseMode
|
||||
{
|
||||
Sync = 0,
|
||||
Async = 1,
|
||||
}
|
||||
|
||||
public enum CallbackTrigger
|
||||
{
|
||||
OnComplete = 0,
|
||||
OnSuccess = 1,
|
||||
OnFailure = 2,
|
||||
}
|
||||
|
||||
public enum CallbackHttpMethod
|
||||
{
|
||||
Post = 0,
|
||||
Put = 1,
|
||||
}
|
||||
|
||||
public enum HttpsBindingKind
|
||||
{
|
||||
None = 0,
|
||||
PfxFile = 1,
|
||||
CertStoreThumbprint = 2,
|
||||
}
|
||||
|
||||
public enum RunAsMode
|
||||
{
|
||||
/// <summary>Run as whatever account the service itself runs under (default).</summary>
|
||||
Service = 0,
|
||||
|
||||
/// <summary>Run as a specific username + password (batch logon, no UI).</summary>
|
||||
SpecificUser = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Run in the active console session under whoever is logged in at the keyboard.
|
||||
/// Lets hooks pop interactive UI on the user's desktop.
|
||||
/// </summary>
|
||||
InteractiveUser = 2,
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace WebhookServer.Core.Models;
|
||||
|
||||
public sealed class HmacOptions
|
||||
{
|
||||
public HmacAlgorithm Algorithm { get; set; } = HmacAlgorithm.Sha256;
|
||||
public string HeaderName { get; set; } = "X-Hub-Signature-256";
|
||||
public string Prefix { get; set; } = "sha256=";
|
||||
public HmacEncoding Encoding { get; set; } = HmacEncoding.Hex;
|
||||
public ProtectedString Secret { get; set; } = new();
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
|
||||
namespace WebhookServer.Core.Models;
|
||||
|
||||
public sealed class HttpsBinding
|
||||
{
|
||||
public HttpsBindingKind Kind { get; set; } = HttpsBindingKind.None;
|
||||
public int Port { get; set; } = 8443;
|
||||
|
||||
/// <summary>Path to a .pfx file when Kind = PfxFile.</summary>
|
||||
public string? PfxPath { get; set; }
|
||||
public ProtectedString? PfxPassword { get; set; }
|
||||
|
||||
/// <summary>Cert thumbprint when Kind = CertStoreThumbprint.</summary>
|
||||
public string? Thumbprint { get; set; }
|
||||
public StoreLocation StoreLocation { get; set; } = StoreLocation.LocalMachine;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace WebhookServer.Core.Models;
|
||||
|
||||
/// <summary>
|
||||
/// A secret value. <see cref="Encrypted"/> is the persistent (DPAPI-protected) form;
|
||||
/// <see cref="Plaintext"/> is transient — the GUI sets it when submitting a new value
|
||||
/// over the named pipe, and the service sets it after decrypting on load. Disk JSON
|
||||
/// must never carry plaintext: <see cref="Storage.ConfigStore.SaveAsync"/> encrypts
|
||||
/// then clears <see cref="Plaintext"/> before writing.
|
||||
/// </summary>
|
||||
public sealed class ProtectedString
|
||||
{
|
||||
[JsonPropertyName("encrypted")]
|
||||
public string? Encrypted { get; set; }
|
||||
|
||||
[JsonPropertyName("plaintext")]
|
||||
public string? Plaintext { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public bool HasValue =>
|
||||
!string.IsNullOrEmpty(Encrypted) || !string.IsNullOrEmpty(Plaintext);
|
||||
|
||||
public static ProtectedString FromPlaintext(string value) =>
|
||||
new() { Plaintext = value };
|
||||
|
||||
public static ProtectedString FromEncrypted(string base64) =>
|
||||
new() { Encrypted = base64 };
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace WebhookServer.Core.Models;
|
||||
|
||||
public sealed class RunAsConfig
|
||||
{
|
||||
public RunAsMode Mode { get; set; } = RunAsMode.Service;
|
||||
|
||||
/// <summary>
|
||||
/// "DOMAIN\user" or "user@upn" or just "user" (local). Required when
|
||||
/// <see cref="Mode"/> is <see cref="RunAsMode.SpecificUser"/>.
|
||||
/// </summary>
|
||||
public string? Username { get; set; }
|
||||
|
||||
/// <summary>DPAPI-protected password for SpecificUser mode.</summary>
|
||||
public ProtectedString? Password { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When true, load the user's profile (HKCU + AppData) before running.
|
||||
/// Slower; only needed for hooks that read user-scope settings.
|
||||
/// </summary>
|
||||
public bool LoadProfile { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace WebhookServer.Core.Models;
|
||||
|
||||
public sealed class ServerConfig
|
||||
{
|
||||
public int HttpPort { get; set; } = 8080;
|
||||
public HttpsBinding? HttpsBinding { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// IP addresses Kestrel binds to. Empty = listen on all interfaces (default).
|
||||
/// Non-empty = listen only on the named addresses.
|
||||
/// </summary>
|
||||
public List<string> BindAddresses { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Hostname or IP that the GUI uses when constructing webhook URLs to display.
|
||||
/// Null = "localhost". Has no effect on what Kestrel actually accepts.
|
||||
/// </summary>
|
||||
public string? DisplayHost { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// IPs/CIDRs allowed to set X-Forwarded-For. Empty = forwarded headers are ignored
|
||||
/// and the direct connection IP is always used.
|
||||
/// </summary>
|
||||
public List<string> TrustedProxies { get; set; } = new();
|
||||
|
||||
public int LogRetentionDays { get; set; } = 14;
|
||||
|
||||
public List<EndpointConfig> Endpoints { get; set; } = new();
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace WebhookServer.Core.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Shared JSON serialization options used for persisting <see cref="Models.ServerConfig"/>
|
||||
/// and for IPC payloads. Keeps formatting and naming consistent.
|
||||
/// </summary>
|
||||
public static class ConfigJson
|
||||
{
|
||||
public static readonly JsonSerializerOptions Pretty = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
|
||||
};
|
||||
|
||||
public static readonly JsonSerializerOptions Compact = new()
|
||||
{
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
using System.Runtime.Versioning;
|
||||
using System.Text.Json;
|
||||
using WebhookServer.Core.Models;
|
||||
|
||||
namespace WebhookServer.Core.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Loads and saves <see cref="ServerConfig"/> JSON. Round-trips secrets through DPAPI:
|
||||
/// on save, any secret that has Plaintext but no Encrypted is protected first; on load
|
||||
/// (when <see cref="DecryptSecrets"/> is called) all Encrypted blobs are unprotected
|
||||
/// into Plaintext for in-memory use.
|
||||
/// </summary>
|
||||
[SupportedOSPlatform("windows")]
|
||||
public sealed class ConfigStore
|
||||
{
|
||||
public string Path { get; }
|
||||
|
||||
public ConfigStore(string path)
|
||||
{
|
||||
Path = path;
|
||||
}
|
||||
|
||||
public async Task<ServerConfig> LoadAsync(CancellationToken ct = default)
|
||||
{
|
||||
if (!File.Exists(Path))
|
||||
return new ServerConfig();
|
||||
|
||||
await using var fs = File.OpenRead(Path);
|
||||
var cfg = await JsonSerializer.DeserializeAsync<ServerConfig>(fs, ConfigJson.Pretty, ct).ConfigureAwait(false);
|
||||
return cfg ?? new ServerConfig();
|
||||
}
|
||||
|
||||
public async Task SaveAsync(ServerConfig config, CancellationToken ct = default)
|
||||
{
|
||||
EncryptSecrets(config);
|
||||
ClearPlaintexts(config);
|
||||
|
||||
var dir = System.IO.Path.GetDirectoryName(Path);
|
||||
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
||||
|
||||
// Snapshot the previous config (if any) into the backups folder before
|
||||
// overwriting. Cheap insurance against typos in the GUI.
|
||||
if (File.Exists(Path) && !string.IsNullOrEmpty(dir))
|
||||
{
|
||||
try
|
||||
{
|
||||
var backupsDir = System.IO.Path.Combine(dir, "backups");
|
||||
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);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Backup is best-effort; don't fail the save if it can't write.
|
||||
}
|
||||
}
|
||||
|
||||
var tmp = Path + ".tmp";
|
||||
await using (var fs = File.Create(tmp))
|
||||
{
|
||||
await JsonSerializer.SerializeAsync(fs, config, ConfigJson.Pretty, ct).ConfigureAwait(false);
|
||||
await fs.FlushAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Atomic replace on the same volume.
|
||||
File.Move(tmp, Path, overwrite: true);
|
||||
}
|
||||
|
||||
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 { }
|
||||
}
|
||||
}
|
||||
|
||||
public static void ClearPlaintexts(ServerConfig config)
|
||||
{
|
||||
foreach (var ep in config.Endpoints)
|
||||
{
|
||||
ClearOne(ep.Bearer?.Secret);
|
||||
ClearOne(ep.Hmac?.Secret);
|
||||
ClearOne(ep.RunAs?.Password);
|
||||
if (ep.Callback is { } cb)
|
||||
{
|
||||
ClearOne(cb.Bearer?.Secret);
|
||||
ClearOne(cb.Hmac?.Secret);
|
||||
}
|
||||
}
|
||||
ClearOne(config.HttpsBinding?.PfxPassword);
|
||||
}
|
||||
|
||||
private static void ClearOne(ProtectedString? s)
|
||||
{
|
||||
if (s is null) return;
|
||||
s.Plaintext = null;
|
||||
}
|
||||
|
||||
public static void DecryptSecrets(ServerConfig config)
|
||||
{
|
||||
foreach (var ep in config.Endpoints)
|
||||
{
|
||||
DecryptOne(ep.Bearer?.Secret);
|
||||
DecryptOne(ep.Hmac?.Secret);
|
||||
DecryptOne(ep.RunAs?.Password);
|
||||
if (ep.Callback is { } cb)
|
||||
{
|
||||
DecryptOne(cb.Bearer?.Secret);
|
||||
DecryptOne(cb.Hmac?.Secret);
|
||||
}
|
||||
}
|
||||
DecryptOne(config.HttpsBinding?.PfxPassword);
|
||||
}
|
||||
|
||||
public static void EncryptSecrets(ServerConfig config)
|
||||
{
|
||||
foreach (var ep in config.Endpoints)
|
||||
{
|
||||
EncryptOne(ep.Bearer?.Secret);
|
||||
EncryptOne(ep.Hmac?.Secret);
|
||||
EncryptOne(ep.RunAs?.Password);
|
||||
if (ep.Callback is { } cb)
|
||||
{
|
||||
EncryptOne(cb.Bearer?.Secret);
|
||||
EncryptOne(cb.Hmac?.Secret);
|
||||
}
|
||||
}
|
||||
EncryptOne(config.HttpsBinding?.PfxPassword);
|
||||
}
|
||||
|
||||
private static void DecryptOne(ProtectedString? s)
|
||||
{
|
||||
if (s is null) return;
|
||||
if (!string.IsNullOrEmpty(s.Plaintext)) return; // already populated
|
||||
if (string.IsNullOrEmpty(s.Encrypted)) return;
|
||||
s.Plaintext = DpapiSecret.Unprotect(s.Encrypted);
|
||||
}
|
||||
|
||||
private static void EncryptOne(ProtectedString? s)
|
||||
{
|
||||
if (s is null) return;
|
||||
if (string.IsNullOrEmpty(s.Plaintext)) return;
|
||||
// Always re-encrypt when plaintext is present so secret rotation is honored.
|
||||
s.Encrypted = DpapiSecret.Protect(s.Plaintext);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using System.Runtime.Versioning;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace WebhookServer.Core.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// DPAPI helpers using <see cref="DataProtectionScope.LocalMachine"/> so the same machine
|
||||
/// (regardless of which Windows account the service runs under) can decrypt config secrets.
|
||||
/// Wire format is plain base64 of the protected blob — caller wraps in JSON.
|
||||
/// </summary>
|
||||
[SupportedOSPlatform("windows")]
|
||||
public static class DpapiSecret
|
||||
{
|
||||
public static string Protect(string plaintext)
|
||||
{
|
||||
if (string.IsNullOrEmpty(plaintext)) return "";
|
||||
var bytes = Encoding.UTF8.GetBytes(plaintext);
|
||||
var blob = ProtectedData.Protect(bytes, optionalEntropy: null, DataProtectionScope.LocalMachine);
|
||||
return Convert.ToBase64String(blob);
|
||||
}
|
||||
|
||||
public static string Unprotect(string base64)
|
||||
{
|
||||
if (string.IsNullOrEmpty(base64)) return "";
|
||||
var blob = Convert.FromBase64String(base64);
|
||||
var bytes = ProtectedData.Unprotect(blob, optionalEntropy: null, DataProtectionScope.LocalMachine);
|
||||
return Encoding.UTF8.GetString(bytes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" />
|
||||
<PackageReference Include="System.IO.Pipes.AccessControl" Version="5.0.0" />
|
||||
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,13 @@
|
||||
<Application x:Class="WebhookServer.Gui.App"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:conv="clr-namespace:WebhookServer.Gui.Converters"
|
||||
StartupUri="MainWindow.xaml">
|
||||
<Application.Resources>
|
||||
<conv:NullToBoolConverter x:Key="NotNull"/>
|
||||
<conv:BoolToBrushConverter x:Key="ConnFill"/>
|
||||
<conv:StringEqualsConverter x:Key="StringEqualsConverter"/>
|
||||
<conv:HookUrlConverter x:Key="HookUrl"/>
|
||||
<conv:InvertBoolConverter x:Key="InvertBool"/>
|
||||
</Application.Resources>
|
||||
</Application>
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace WebhookServer.Gui;
|
||||
|
||||
public partial class App : Application
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using System.Windows;
|
||||
|
||||
[assembly:ThemeInfo(
|
||||
ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located
|
||||
//(used if a resource is not found in the page,
|
||||
// or application resource dictionaries)
|
||||
ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located
|
||||
//(used if a resource is not found in the page,
|
||||
// app, or any theme specific resource dictionaries)
|
||||
)]
|
||||
@@ -0,0 +1,60 @@
|
||||
using System.Globalization;
|
||||
using System.Windows.Data;
|
||||
using Brush = System.Windows.Media.Brush;
|
||||
using Brushes = System.Windows.Media.Brushes;
|
||||
|
||||
namespace WebhookServer.Gui.Converters;
|
||||
|
||||
public sealed class NullToBoolConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> value is not null;
|
||||
|
||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public sealed class HookUrlConverter : IMultiValueConverter
|
||||
{
|
||||
public object Convert(object[] values, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
if (values.Length < 2) return "";
|
||||
var slug = values[0] as string ?? "";
|
||||
var baseUrl = values[1] as string ?? "";
|
||||
if (string.IsNullOrEmpty(baseUrl) || string.IsNullOrEmpty(slug)) return "";
|
||||
return $"{baseUrl.TrimEnd('/')}/hook/{slug}";
|
||||
}
|
||||
|
||||
public object[] ConvertBack(object? value, Type[] targetTypes, object? parameter, CultureInfo culture)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public sealed class InvertBoolConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> value is bool b && !b;
|
||||
|
||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> value is bool b && !b;
|
||||
}
|
||||
|
||||
public sealed class StringEqualsConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> string.Equals(value as string, parameter as string, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> (value is bool b && b) ? parameter : Binding.DoNothing;
|
||||
}
|
||||
|
||||
public sealed class BoolToBrushConverter : IValueConverter
|
||||
{
|
||||
public Brush TrueBrush { get; set; } = Brushes.SeaGreen;
|
||||
public Brush FalseBrush { get; set; } = Brushes.IndianRed;
|
||||
|
||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> (value is bool b && b) ? TrueBrush : FalseBrush;
|
||||
|
||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// Enabling UseWindowsForms (for the system tray NotifyIcon) brings the WinForms
|
||||
// namespace into scope, which conflicts with WPF for several common type names.
|
||||
// Alias the most-used types to their WPF variants project-wide so existing code
|
||||
// keeps compiling. Files that genuinely need a WinForms type import it explicitly
|
||||
// (System.Windows.Forms.NotifyIcon etc. in Services/TrayIcon.cs).
|
||||
|
||||
global using Application = System.Windows.Application;
|
||||
global using MessageBox = System.Windows.MessageBox;
|
||||
global using Clipboard = System.Windows.Clipboard;
|
||||
global using TextBox = System.Windows.Controls.TextBox;
|
||||
global using RadioButton = System.Windows.Controls.RadioButton;
|
||||
global using MessageBoxButton = System.Windows.MessageBoxButton;
|
||||
global using MessageBoxImage = System.Windows.MessageBoxImage;
|
||||
global using MessageBoxResult = System.Windows.MessageBoxResult;
|
||||
global using Binding = System.Windows.Data.Binding;
|
||||
@@ -0,0 +1,156 @@
|
||||
<Window x:Class="WebhookServer.Gui.MainWindow"
|
||||
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"
|
||||
xmlns:models="clr-namespace:WebhookServer.Core.Models;assembly=WebhookServer.Core"
|
||||
mc:Ignorable="d"
|
||||
Title="Webhook Server" Height="600" Width="1000"
|
||||
Icon="/webhook-server.ico"
|
||||
d:DataContext="{d:DesignInstance Type=vm:MainViewModel}">
|
||||
<Window.InputBindings>
|
||||
<KeyBinding Key="N" Modifiers="Control" Command="{Binding AddEndpointCommand}"/>
|
||||
</Window.InputBindings>
|
||||
<DockPanel LastChildFill="True">
|
||||
<StatusBar DockPanel.Dock="Bottom">
|
||||
<StatusBarItem>
|
||||
<Ellipse Width="10" Height="10"
|
||||
Fill="{Binding IsConnected, Converter={StaticResource ConnFill}}"/>
|
||||
</StatusBarItem>
|
||||
<StatusBarItem>
|
||||
<TextBlock Text="{Binding ConnectionStatus}"/>
|
||||
</StatusBarItem>
|
||||
</StatusBar>
|
||||
|
||||
<Menu DockPanel.Dock="Top">
|
||||
<MenuItem Header="_File">
|
||||
<MenuItem Header="_New endpoint…" Command="{Binding AddEndpointCommand}" InputGestureText="Ctrl+N"/>
|
||||
<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."/>
|
||||
<Separator/>
|
||||
<MenuItem Header="E_xit" Command="{Binding ExitCommand}"/>
|
||||
</MenuItem>
|
||||
<MenuItem Header="_Server">
|
||||
<MenuItem Header="_Settings…" Command="{Binding EditServerSettingsCommand}"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="_Restart service" Command="{Binding RestartServiceCommand}"/>
|
||||
</MenuItem>
|
||||
<MenuItem Header="_Help">
|
||||
<MenuItem Header="_Documentation…" Command="{Binding OpenDocumentationCommand}"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="_About Webhook Server…" Command="{Binding ShowAboutCommand}"/>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="5"/>
|
||||
<RowDefinition Height="200"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<DataGrid Grid.Row="0"
|
||||
ItemsSource="{Binding Endpoints}"
|
||||
SelectedItem="{Binding SelectedEndpoint, Mode=TwoWay}"
|
||||
AutoGenerateColumns="False"
|
||||
CanUserAddRows="False"
|
||||
CanUserDeleteRows="False"
|
||||
IsReadOnly="True"
|
||||
HeadersVisibility="Column">
|
||||
<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}}"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="Toggle _enabled"
|
||||
Command="{Binding PlacementTarget.Tag.ToggleEnabledCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
|
||||
CommandParameter="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource AncestorType=ContextMenu}}"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="_Delete…"
|
||||
Command="{Binding PlacementTarget.Tag.DeleteEndpointCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"/>
|
||||
</ContextMenu>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
</DataGrid.RowStyle>
|
||||
<DataGrid.Columns>
|
||||
<DataGridTemplateColumn Header="Enabled" Width="80">
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate DataType="{x:Type models:EndpointConfig}">
|
||||
<CheckBox IsChecked="{Binding Enabled, Mode=OneWay}"
|
||||
Command="{Binding DataContext.ToggleEnabledCommand, RelativeSource={RelativeSource AncestorType=Window}}"
|
||||
CommandParameter="{Binding}"/>
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</DataGridTemplateColumn>
|
||||
<DataGridTextColumn Header="Slug" Binding="{Binding Slug}" Width="120"/>
|
||||
<DataGridTemplateColumn Header="URL" Width="*">
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate DataType="{x:Type models:EndpointConfig}">
|
||||
<TextBox IsReadOnly="True"
|
||||
BorderThickness="0"
|
||||
Background="Transparent"
|
||||
Padding="0"
|
||||
VerticalAlignment="Center">
|
||||
<TextBox.Text>
|
||||
<MultiBinding Converter="{StaticResource HookUrl}" Mode="OneWay">
|
||||
<Binding Path="Slug"/>
|
||||
<Binding Path="DataContext.HttpBaseUrl" RelativeSource="{RelativeSource AncestorType=Window}"/>
|
||||
</MultiBinding>
|
||||
</TextBox.Text>
|
||||
</TextBox>
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</DataGridTemplateColumn>
|
||||
<DataGridTextColumn Header="Auth" Binding="{Binding AuthMode}" Width="80"/>
|
||||
<DataGridTextColumn Header="Executor" Binding="{Binding ExecutorType}" Width="140"/>
|
||||
<DataGridTextColumn Header="Mode" Binding="{Binding ResponseMode}" Width="80"/>
|
||||
<DataGridTextColumn Header="Description" Binding="{Binding Description}" Width="2*"/>
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
|
||||
<GridSplitter Grid.Row="1" HorizontalAlignment="Stretch" Background="#DDD"/>
|
||||
|
||||
<DockPanel Grid.Row="2">
|
||||
<Grid DockPanel.Dock="Top">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Text="Recent log entries" FontWeight="Bold" Margin="6,4"/>
|
||||
<CheckBox Grid.Column="1" Content="Auto-scroll" IsChecked="{Binding AutoScrollLogs}" VerticalAlignment="Center" Margin="6,2"/>
|
||||
<Button Grid.Column="2" Content="Refresh" Command="{Binding RefreshLogTailCommand}" Margin="6,2"/>
|
||||
</Grid>
|
||||
<TextBox x:Name="LogTailBox"
|
||||
Text="{Binding LogTail, Mode=OneWay}"
|
||||
IsReadOnly="True"
|
||||
FontFamily="Consolas"
|
||||
VerticalScrollBarVisibility="Auto"
|
||||
HorizontalScrollBarVisibility="Auto"
|
||||
TextWrapping="NoWrap"
|
||||
TextChanged="OnLogTailChanged"/>
|
||||
</DockPanel>
|
||||
</Grid>
|
||||
</DockPanel>
|
||||
</Window>
|
||||
@@ -0,0 +1,87 @@
|
||||
using System.ComponentModel;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using WebhookServer.Gui.Services;
|
||||
using WebhookServer.Gui.ViewModels;
|
||||
|
||||
namespace WebhookServer.Gui;
|
||||
|
||||
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);
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
Hide();
|
||||
ShowInTaskbar = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
ShowInTaskbar = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnLogTailChanged(object sender, TextChangedEventArgs e)
|
||||
{
|
||||
if (DataContext is MainViewModel vm && vm.AutoScrollLogs && sender is TextBox box)
|
||||
box.ScrollToEnd();
|
||||
}
|
||||
|
||||
private void OnRowDoubleClick(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (DataContext is MainViewModel vm && vm.EditEndpointCommand.CanExecute(null))
|
||||
vm.EditEndpointCommand.Execute(null);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using System.IO;
|
||||
using System.IO.Pipes;
|
||||
using System.Runtime.Versioning;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using WebhookServer.Core.Ipc;
|
||||
using WebhookServer.Core.Models;
|
||||
|
||||
namespace WebhookServer.Gui.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Thin client around the admin named pipe. Each call connects, sends one request,
|
||||
/// reads one response, and disconnects — keeps lifecycle simple at the cost of
|
||||
/// connect-per-call overhead. The service single-instance pipe queues requests so
|
||||
/// concurrent calls from the GUI serialize automatically.
|
||||
/// </summary>
|
||||
[SupportedOSPlatform("windows")]
|
||||
public sealed class AdminPipeClient
|
||||
{
|
||||
public TimeSpan ConnectTimeout { get; init; } = TimeSpan.FromSeconds(5);
|
||||
|
||||
public async Task<AdminResponse> InvokeAsync(string op, object? data = null, CancellationToken ct = default)
|
||||
{
|
||||
var request = new AdminRequest
|
||||
{
|
||||
Op = op,
|
||||
Data = data is null
|
||||
? null
|
||||
: JsonSerializer.SerializeToDocument(data, AdminProtocol.JsonOptions).RootElement.Clone(),
|
||||
};
|
||||
|
||||
await using var pipe = new NamedPipeClientStream(
|
||||
".",
|
||||
PipeSecurityFactory.PipeName,
|
||||
PipeDirection.InOut,
|
||||
PipeOptions.Asynchronous);
|
||||
|
||||
await pipe.ConnectAsync((int)ConnectTimeout.TotalMilliseconds, ct).ConfigureAwait(false);
|
||||
|
||||
await PipeFraming.WriteAsync(pipe, request, ct).ConfigureAwait(false);
|
||||
|
||||
using var reader = PipeFraming.CreateReader(pipe);
|
||||
var response = await PipeFraming.ReadAsync<AdminResponse>(reader, ct).ConfigureAwait(false);
|
||||
return response ?? AdminResponse.Failure("empty response from service");
|
||||
}
|
||||
|
||||
public async Task<T?> InvokeAsync<T>(string op, object? data = null, CancellationToken ct = default) where T : class
|
||||
{
|
||||
var resp = await InvokeAsync(op, data, ct).ConfigureAwait(false);
|
||||
if (!resp.Ok || resp.Data is null) return null;
|
||||
return resp.Data.Value.Deserialize<T>(AdminProtocol.JsonOptions);
|
||||
}
|
||||
|
||||
public Task<AdminResponse> PingAsync(CancellationToken ct = default) =>
|
||||
InvokeAsync(AdminOps.Ping, null, ct);
|
||||
|
||||
public Task<StatusInfo?> GetStatusAsync(CancellationToken ct = default) =>
|
||||
InvokeAsync<StatusInfo>(AdminOps.GetStatus, null, ct);
|
||||
|
||||
public Task<ServerConfig?> GetConfigAsync(CancellationToken ct = default) =>
|
||||
InvokeAsync<ServerConfig>(AdminOps.GetConfig, null, ct);
|
||||
|
||||
public Task<EndpointConfig?> CreateEndpointAsync(EndpointConfig endpoint, CancellationToken ct = default) =>
|
||||
InvokeAsync<EndpointConfig>(AdminOps.CreateEndpoint, endpoint, ct);
|
||||
|
||||
public Task<EndpointConfig?> UpdateEndpointAsync(EndpointConfig endpoint, CancellationToken ct = default) =>
|
||||
InvokeAsync<EndpointConfig>(AdminOps.UpdateEndpoint, endpoint, ct);
|
||||
|
||||
public Task<AdminResponse> DeleteEndpointAsync(Guid id, CancellationToken ct = default) =>
|
||||
InvokeAsync(AdminOps.DeleteEndpoint, new DeleteEndpointArgs { Id = id }, ct);
|
||||
|
||||
public Task<AdminResponse> SetEndpointEnabledAsync(Guid id, bool enabled, CancellationToken ct = default) =>
|
||||
InvokeAsync(enabled ? AdminOps.EnableEndpoint : AdminOps.DisableEndpoint, new EndpointToggle { Id = id }, ct);
|
||||
|
||||
public Task<AdminResponse> BindHttpsAsync(HttpsBinding? binding, CancellationToken ct = default) =>
|
||||
InvokeAsync(AdminOps.BindHttps, binding, ct);
|
||||
|
||||
public Task<AdminResponse> RestartListenerAsync(CancellationToken ct = default) =>
|
||||
InvokeAsync(AdminOps.RestartListener, null, ct);
|
||||
|
||||
public async Task<List<LogLine>> TailLogsAsync(int lines, CancellationToken ct = default)
|
||||
{
|
||||
var resp = await InvokeAsync(AdminOps.TailLogs, new TailLogsArgs { LinesToBacklog = lines, Follow = false }, ct).ConfigureAwait(false);
|
||||
if (!resp.Ok || resp.Data is null) return new List<LogLine>();
|
||||
var lst = resp.Data.Value.GetProperty("lines").Deserialize<List<LogLine>>(AdminProtocol.JsonOptions);
|
||||
return lst ?? new List<LogLine>();
|
||||
}
|
||||
|
||||
public async Task<List<BackupEntry>> ListBackupsAsync(CancellationToken ct = default)
|
||||
{
|
||||
var resp = await InvokeAsync(AdminOps.ListBackups, null, ct).ConfigureAwait(false);
|
||||
if (!resp.Ok || resp.Data is null) return new List<BackupEntry>();
|
||||
var lst = resp.Data.Value.GetProperty("backups").Deserialize<List<BackupEntry>>(AdminProtocol.JsonOptions);
|
||||
return lst ?? new List<BackupEntry>();
|
||||
}
|
||||
|
||||
public Task<AdminResponse> RestoreBackupAsync(string fileName, CancellationToken ct = default) =>
|
||||
InvokeAsync(AdminOps.RestoreBackup, new RestoreBackupArgs { FileName = fileName }, ct);
|
||||
|
||||
public Task<AdminResponse> ImportConfigAsync(ServerConfig config, CancellationToken ct = default) =>
|
||||
InvokeAsync(AdminOps.ImportConfig, config, ct);
|
||||
|
||||
public Task<BackupEntry?> CreateCheckpointAsync(string? description, CancellationToken ct = default) =>
|
||||
InvokeAsync<BackupEntry>(AdminOps.CreateCheckpoint, new CreateCheckpointArgs { Description = description }, ct);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace WebhookServer.Gui.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Per-user GUI preferences that don't belong in the service-side ServerConfig.
|
||||
/// Persisted to %APPDATA%\WebhookServer\gui.json. Best-effort: failures to read
|
||||
/// or write fall back silently to defaults.
|
||||
/// </summary>
|
||||
public sealed class GuiSettings
|
||||
{
|
||||
/// <summary>
|
||||
/// When true, the X / Alt+F4 / minimize buttons hide the window to the tray
|
||||
/// and keep the GUI process alive. When false, X exits the app and minimize
|
||||
/// behaves like a normal Windows minimize.
|
||||
/// </summary>
|
||||
public bool MinimizeToTrayEnabled { get; set; } = true;
|
||||
|
||||
private static string FilePath => Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"WebhookServer",
|
||||
"gui.json");
|
||||
|
||||
public static GuiSettings Load()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(FilePath))
|
||||
{
|
||||
var json = File.ReadAllText(FilePath);
|
||||
if (!string.IsNullOrWhiteSpace(json))
|
||||
return JsonSerializer.Deserialize<GuiSettings>(json) ?? new GuiSettings();
|
||||
}
|
||||
}
|
||||
catch { /* fall through to defaults */ }
|
||||
return new GuiSettings();
|
||||
}
|
||||
|
||||
public void Save()
|
||||
{
|
||||
try
|
||||
{
|
||||
var dir = Path.GetDirectoryName(FilePath);
|
||||
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
||||
File.WriteAllText(FilePath, JsonSerializer.Serialize(this, new JsonSerializerOptions { WriteIndented = true }));
|
||||
}
|
||||
catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using System.Drawing;
|
||||
using System.Runtime.Versioning;
|
||||
using System.Windows;
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace WebhookServer.Gui.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal system tray icon using Windows Forms NotifyIcon. Owns a context menu
|
||||
/// (Open / Restart service / Exit) and toggles the main window visibility on
|
||||
/// double-click. Hide-to-tray on minimize is wired in MainWindow.xaml.cs.
|
||||
/// </summary>
|
||||
[SupportedOSPlatform("windows")]
|
||||
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)
|
||||
{
|
||||
_resolveMainWindow = resolveMainWindow;
|
||||
_restartServiceAsync = restartServiceAsync;
|
||||
_onExit = onExit;
|
||||
|
||||
_icon = new NotifyIcon
|
||||
{
|
||||
Icon = LoadEmbeddedIcon(),
|
||||
Text = "Webhook Server",
|
||||
Visible = true,
|
||||
};
|
||||
_icon.DoubleClick += (_, _) => ShowMainWindow();
|
||||
_icon.ContextMenuStrip = BuildMenu();
|
||||
}
|
||||
|
||||
private ContextMenuStrip BuildMenu()
|
||||
{
|
||||
var menu = new ContextMenuStrip();
|
||||
menu.Items.Add("&Open Webhook Server", null, (_, _) => ShowMainWindow());
|
||||
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());
|
||||
return menu;
|
||||
}
|
||||
|
||||
private void ShowMainWindow()
|
||||
{
|
||||
var w = _resolveMainWindow();
|
||||
if (w is null) return;
|
||||
if (w.WindowState == WindowState.Minimized) w.WindowState = WindowState.Normal;
|
||||
w.Show();
|
||||
w.Activate();
|
||||
w.Topmost = true;
|
||||
w.Topmost = false;
|
||||
}
|
||||
|
||||
private static Icon LoadEmbeddedIcon()
|
||||
{
|
||||
// Pulled from the WPF Resource items in the csproj via the application
|
||||
// pack URI. Falling back to SystemIcons keeps the tray usable if the
|
||||
// resource is somehow missing.
|
||||
try
|
||||
{
|
||||
var uri = new Uri("pack://application:,,,/webhook-server.ico", UriKind.Absolute);
|
||||
using var stream = Application.GetResourceStream(uri).Stream;
|
||||
return new Icon(stream);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return SystemIcons.Application;
|
||||
}
|
||||
}
|
||||
|
||||
public void ShowBalloon(string title, string message)
|
||||
{
|
||||
_icon.BalloonTipTitle = title;
|
||||
_icon.BalloonTipText = message;
|
||||
_icon.ShowBalloonTip(3000);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_icon.Visible = false;
|
||||
_icon.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Runtime.Versioning;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using WebhookServer.Core.Ipc;
|
||||
using WebhookServer.Gui.Services;
|
||||
|
||||
namespace WebhookServer.Gui.ViewModels;
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
public sealed partial class ConfigCheckpointsViewModel : ObservableObject
|
||||
{
|
||||
private readonly AdminPipeClient _client;
|
||||
|
||||
public ObservableCollection<BackupEntry> Checkpoints { get; } = new();
|
||||
|
||||
[ObservableProperty] private BackupEntry? _selected;
|
||||
[ObservableProperty] private string _statusMessage = "";
|
||||
|
||||
public ConfigCheckpointsViewModel(AdminPipeClient client)
|
||||
{
|
||||
_client = client;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
public async Task RefreshAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var list = await _client.ListBackupsAsync().ConfigureAwait(false);
|
||||
Application.Current.Dispatcher.Invoke(() =>
|
||||
{
|
||||
Checkpoints.Clear();
|
||||
foreach (var b in list) Checkpoints.Add(b);
|
||||
StatusMessage = list.Count == 0
|
||||
? "No checkpoints yet. Save the config or click Take Checkpoint Now."
|
||||
: $"{list.Count} checkpoint{(list.Count == 1 ? "" : "s")}.";
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Application.Current.Dispatcher.Invoke(() => StatusMessage = $"Could not load: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task TakeCheckpointAsync()
|
||||
{
|
||||
// Prompt for an optional description on the UI thread.
|
||||
string? description = null;
|
||||
var prompted = Application.Current.Dispatcher.Invoke(() =>
|
||||
{
|
||||
var dlg = new Views.TakeCheckpointDialog { Owner = Application.Current.MainWindow };
|
||||
if (dlg.ShowDialog() != true) return false;
|
||||
description = string.IsNullOrWhiteSpace(dlg.Description) ? null : dlg.Description;
|
||||
return true;
|
||||
});
|
||||
if (!prompted) return;
|
||||
|
||||
try
|
||||
{
|
||||
var entry = await _client.CreateCheckpointAsync(description).ConfigureAwait(false);
|
||||
await RefreshAsync().ConfigureAwait(false);
|
||||
if (entry is not null)
|
||||
{
|
||||
Application.Current.Dispatcher.Invoke(() =>
|
||||
{
|
||||
Selected = Checkpoints.FirstOrDefault(c => c.FileName == entry.FileName);
|
||||
StatusMessage = $"Created {entry.FileName}";
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Application.Current.Dispatcher.Invoke(() =>
|
||||
MessageBox.Show(ex.Message, "Take checkpoint failed", MessageBoxButton.OK, MessageBoxImage.Error));
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task RollbackAsync()
|
||||
{
|
||||
if (Selected is null) return;
|
||||
|
||||
// Capture before the refresh; the ObservableCollection.Clear() in
|
||||
// RefreshAsync nulls Selected (the original instance is gone from the
|
||||
// collection so the SelectedItem binding clears).
|
||||
var fileName = Selected.FileName;
|
||||
var savedAt = Selected.SavedAt;
|
||||
|
||||
var ok = MessageBox.Show(
|
||||
$"Roll the configuration back to the checkpoint from {savedAt.ToLocalTime():yyyy-MM-dd HH:mm:ss}?\n\nThe current configuration is automatically saved as a new checkpoint first, so you can roll forward again.",
|
||||
"Confirm rollback",
|
||||
MessageBoxButton.OKCancel,
|
||||
MessageBoxImage.Warning);
|
||||
if (ok != MessageBoxResult.OK) return;
|
||||
|
||||
try
|
||||
{
|
||||
await _client.RestoreBackupAsync(fileName).ConfigureAwait(false);
|
||||
await RefreshAsync().ConfigureAwait(false);
|
||||
Application.Current.Dispatcher.Invoke(() =>
|
||||
StatusMessage = $"Rolled back to {fileName}.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Application.Current.Dispatcher.Invoke(() =>
|
||||
MessageBox.Show(ex.Message, "Rollback failed", MessageBoxButton.OK, MessageBoxImage.Error));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
using System.Runtime.Versioning;
|
||||
using System.Text.Json;
|
||||
using System.Windows;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using WebhookServer.Core.Models;
|
||||
using WebhookServer.Core.Storage;
|
||||
|
||||
namespace WebhookServer.Gui.ViewModels;
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
public sealed partial class EndpointEditorViewModel : ObservableObject
|
||||
{
|
||||
public EndpointConfig Endpoint { get; }
|
||||
public bool IsNew { get; }
|
||||
|
||||
[ObservableProperty] private bool _accepted;
|
||||
|
||||
public EndpointEditorViewModel(EndpointConfig template, bool isNew)
|
||||
{
|
||||
// Deep clone via JSON so cancel-on-close cleanly drops edits.
|
||||
var json = JsonSerializer.Serialize(template, ConfigJson.Compact);
|
||||
Endpoint = JsonSerializer.Deserialize<EndpointConfig>(json, ConfigJson.Compact)!;
|
||||
Endpoint.Bearer ??= new BearerOptions();
|
||||
Endpoint.Hmac ??= new HmacOptions();
|
||||
Endpoint.RunAs ??= new RunAsConfig();
|
||||
Endpoint.RunAs.Password ??= new ProtectedString();
|
||||
IsNew = isNew;
|
||||
}
|
||||
|
||||
public Array AuthModes { get; } = Enum.GetValues(typeof(AuthMode));
|
||||
public Array ExecutorTypes { get; } = Enum.GetValues(typeof(ExecutorType));
|
||||
public Array ResponseModes { get; } = Enum.GetValues(typeof(ResponseMode));
|
||||
|
||||
/// <summary>
|
||||
/// Proxy for <see cref="EndpointConfig.AuthMode"/> that emits change notifications
|
||||
/// for the visibility flags so the bearer/HMAC sections show/hide reactively.
|
||||
/// </summary>
|
||||
public AuthMode SelectedAuthMode
|
||||
{
|
||||
get => Endpoint.AuthMode;
|
||||
set
|
||||
{
|
||||
if (Endpoint.AuthMode == value) return;
|
||||
Endpoint.AuthMode = value;
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(BearerVisible));
|
||||
OnPropertyChanged(nameof(HmacVisible));
|
||||
}
|
||||
}
|
||||
|
||||
public Visibility BearerVisible =>
|
||||
Endpoint.AuthMode == AuthMode.Bearer ? Visibility.Visible : Visibility.Collapsed;
|
||||
|
||||
public Visibility HmacVisible =>
|
||||
Endpoint.AuthMode == AuthMode.Hmac ? Visibility.Visible : Visibility.Collapsed;
|
||||
|
||||
public Array RunAsModes { get; } = Enum.GetValues(typeof(RunAsMode));
|
||||
|
||||
public RunAsMode SelectedRunAsMode
|
||||
{
|
||||
get => Endpoint.RunAs?.Mode ?? RunAsMode.Service;
|
||||
set
|
||||
{
|
||||
Endpoint.RunAs ??= new RunAsConfig();
|
||||
if (Endpoint.RunAs.Mode == value) return;
|
||||
Endpoint.RunAs.Mode = value;
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(SpecificUserVisible));
|
||||
}
|
||||
}
|
||||
|
||||
public Visibility SpecificUserVisible =>
|
||||
SelectedRunAsMode == RunAsMode.SpecificUser ? Visibility.Visible : Visibility.Collapsed;
|
||||
|
||||
public string RunAsUsername
|
||||
{
|
||||
get => Endpoint.RunAs?.Username ?? "";
|
||||
set
|
||||
{
|
||||
Endpoint.RunAs ??= new RunAsConfig();
|
||||
Endpoint.RunAs.Username = string.IsNullOrEmpty(value) ? null : value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public string RunAsPassword
|
||||
{
|
||||
get => Endpoint.RunAs?.Password?.Plaintext ?? "";
|
||||
set
|
||||
{
|
||||
Endpoint.RunAs ??= new RunAsConfig();
|
||||
Endpoint.RunAs.Password ??= new ProtectedString();
|
||||
Endpoint.RunAs.Password.Plaintext = string.IsNullOrEmpty(value) ? null : value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public bool RunAsLoadProfile
|
||||
{
|
||||
get => Endpoint.RunAs?.LoadProfile ?? false;
|
||||
set
|
||||
{
|
||||
Endpoint.RunAs ??= new RunAsConfig();
|
||||
Endpoint.RunAs.LoadProfile = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public string AllowedClientsText
|
||||
{
|
||||
get => string.Join(Environment.NewLine, Endpoint.AllowedClients);
|
||||
set
|
||||
{
|
||||
Endpoint.AllowedClients = (value ?? "").Split(new[] { '\r', '\n', ',' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public string ExecutableArgsText
|
||||
{
|
||||
get => string.Join(" ", Endpoint.ExecutableArgs);
|
||||
set
|
||||
{
|
||||
Endpoint.ExecutableArgs = (value ?? "").Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public string BearerSecret
|
||||
{
|
||||
get => Endpoint.Bearer?.Secret.Plaintext ?? "";
|
||||
set
|
||||
{
|
||||
Endpoint.Bearer ??= new BearerOptions();
|
||||
Endpoint.Bearer.Secret.Plaintext = string.IsNullOrEmpty(value) ? null : value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public string HmacSecret
|
||||
{
|
||||
get => Endpoint.Hmac?.Secret.Plaintext ?? "";
|
||||
set
|
||||
{
|
||||
Endpoint.Hmac ??= new HmacOptions();
|
||||
Endpoint.Hmac.Secret.Plaintext = string.IsNullOrEmpty(value) ? null : value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Save() => Accepted = true;
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Runtime.Versioning;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows;
|
||||
using System.Windows.Threading;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using WebhookServer.Core.Ipc;
|
||||
using WebhookServer.Core.Models;
|
||||
using WebhookServer.Gui.Services;
|
||||
using WebhookServer.Gui.Views;
|
||||
|
||||
namespace WebhookServer.Gui.ViewModels;
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
public sealed partial class MainViewModel : ObservableObject
|
||||
{
|
||||
private readonly AdminPipeClient _client;
|
||||
|
||||
public ObservableCollection<EndpointConfig> Endpoints { get; } = new();
|
||||
|
||||
[ObservableProperty] private EndpointConfig? _selectedEndpoint;
|
||||
[ObservableProperty] private string _connectionStatus = "Disconnected";
|
||||
[ObservableProperty] private bool _isConnected;
|
||||
[ObservableProperty] private string _logTail = "";
|
||||
[ObservableProperty] private bool _autoScrollLogs = true;
|
||||
[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()
|
||||
{
|
||||
try
|
||||
{
|
||||
var status = await _client.GetStatusAsync().ConfigureAwait(false);
|
||||
var config = await _client.GetConfigAsync().ConfigureAwait(false);
|
||||
|
||||
Application.Current.Dispatcher.Invoke(() =>
|
||||
{
|
||||
IsConnected = status?.Running == true;
|
||||
ConnectionStatus = IsConnected
|
||||
? $"Connected — HTTP {status!.HttpPort}{(status.HttpsPort.HasValue ? $" / HTTPS {status.HttpsPort}" : "")}"
|
||||
: "Disconnected";
|
||||
|
||||
if (status is not null)
|
||||
{
|
||||
var host = string.IsNullOrEmpty(status.DisplayHost) ? "localhost" : status.DisplayHost;
|
||||
HttpBaseUrl = $"http://{host}:{status.HttpPort}";
|
||||
HttpsBaseUrl = status.HttpsPort.HasValue ? $"https://{host}:{status.HttpsPort.Value}" : null;
|
||||
}
|
||||
|
||||
Endpoints.Clear();
|
||||
if (config is not null)
|
||||
{
|
||||
ServerConfig = config;
|
||||
foreach (var ep in config.Endpoints) Endpoints.Add(ep);
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Application.Current.Dispatcher.Invoke(() =>
|
||||
{
|
||||
IsConnected = false;
|
||||
ConnectionStatus = $"Disconnected: {ex.Message}";
|
||||
});
|
||||
}
|
||||
|
||||
await RefreshLogTailAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task RefreshLogTailAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var lines = await _client.TailLogsAsync(100).ConfigureAwait(false);
|
||||
var text = new StringBuilder();
|
||||
foreach (var line in lines) text.AppendLine(line.Message);
|
||||
Application.Current.Dispatcher.Invoke(() => LogTail = text.ToString());
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore — main connection state already reflects pipe failure
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task AddEndpointAsync()
|
||||
{
|
||||
var draft = new EndpointConfig { Id = Guid.NewGuid(), Slug = "new-hook" };
|
||||
var dlg = new EndpointEditor { Owner = Application.Current.MainWindow };
|
||||
var vm = new EndpointEditorViewModel(draft, isNew: true);
|
||||
dlg.DataContext = vm;
|
||||
if (dlg.ShowDialog() != true) return;
|
||||
|
||||
try
|
||||
{
|
||||
await _client.CreateEndpointAsync(vm.Endpoint).ConfigureAwait(false);
|
||||
await RefreshAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ShowError("Create failed", ex);
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task EditEndpointAsync()
|
||||
{
|
||||
if (SelectedEndpoint is null) return;
|
||||
var dlg = new EndpointEditor { Owner = Application.Current.MainWindow };
|
||||
var vm = new EndpointEditorViewModel(SelectedEndpoint, isNew: false);
|
||||
dlg.DataContext = vm;
|
||||
if (dlg.ShowDialog() != true) return;
|
||||
|
||||
try
|
||||
{
|
||||
await _client.UpdateEndpointAsync(vm.Endpoint).ConfigureAwait(false);
|
||||
await RefreshAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ShowError("Update failed", ex);
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task DeleteEndpointAsync()
|
||||
{
|
||||
if (SelectedEndpoint is null) return;
|
||||
var ok = MessageBox.Show(
|
||||
$"Delete endpoint '{SelectedEndpoint.Slug}'?",
|
||||
"Confirm",
|
||||
MessageBoxButton.OKCancel,
|
||||
MessageBoxImage.Warning);
|
||||
if (ok != MessageBoxResult.OK) return;
|
||||
|
||||
try
|
||||
{
|
||||
await _client.DeleteEndpointAsync(SelectedEndpoint.Id).ConfigureAwait(false);
|
||||
await RefreshAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ShowError("Delete failed", ex);
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task ToggleEnabledAsync(EndpointConfig? ep)
|
||||
{
|
||||
if (ep is null) return;
|
||||
try
|
||||
{
|
||||
await _client.SetEndpointEnabledAsync(ep.Id, !ep.Enabled).ConfigureAwait(false);
|
||||
await RefreshAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ShowError("Toggle failed", ex);
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void ShowConfigCheckpoints()
|
||||
{
|
||||
var dlg = new Views.ConfigCheckpointsDialog
|
||||
{
|
||||
Owner = Application.Current.MainWindow,
|
||||
DataContext = new ConfigCheckpointsViewModel(_client),
|
||||
};
|
||||
dlg.ShowDialog();
|
||||
// After the dialog closes, the live config may have changed via rollback,
|
||||
// so refresh the main grid.
|
||||
_ = RefreshAsync();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task ExportConfigAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var snap = await _client.GetConfigAsync().ConfigureAwait(false);
|
||||
if (snap is null) { ShowError("Export failed", new InvalidOperationException("Service did not return a config.")); return; }
|
||||
|
||||
var dlg = new Microsoft.Win32.SaveFileDialog
|
||||
{
|
||||
FileName = $"webhook-server-config-{DateTime.Now:yyyyMMdd-HHmmss}.json",
|
||||
DefaultExt = ".json",
|
||||
Filter = "JSON config (*.json)|*.json",
|
||||
};
|
||||
if (dlg.ShowDialog() != true) return;
|
||||
|
||||
var json = System.Text.Json.JsonSerializer.Serialize(snap, WebhookServer.Core.Storage.ConfigJson.Pretty);
|
||||
await System.IO.File.WriteAllTextAsync(dlg.FileName, json).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) { ShowError("Export failed", ex); }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task ImportConfigAsync()
|
||||
{
|
||||
var dlg = new Microsoft.Win32.OpenFileDialog
|
||||
{
|
||||
Filter = "JSON config (*.json)|*.json",
|
||||
CheckFileExists = true,
|
||||
};
|
||||
if (dlg.ShowDialog() != true) return;
|
||||
|
||||
try
|
||||
{
|
||||
var json = await System.IO.File.ReadAllTextAsync(dlg.FileName).ConfigureAwait(false);
|
||||
var cfg = System.Text.Json.JsonSerializer.Deserialize<ServerConfig>(json, WebhookServer.Core.Storage.ConfigJson.Pretty);
|
||||
if (cfg is null) throw new InvalidOperationException("File did not contain a valid config.");
|
||||
|
||||
var ok = MessageBox.Show(
|
||||
$"Replace the current configuration with {dlg.FileName}?\n\nA checkpoint of the current config is saved first, so you can roll back from File → Config Checkpoints.",
|
||||
"Import config",
|
||||
MessageBoxButton.OKCancel,
|
||||
MessageBoxImage.Warning);
|
||||
if (ok != MessageBoxResult.OK) return;
|
||||
|
||||
await _client.ImportConfigAsync(cfg).ConfigureAwait(false);
|
||||
await RefreshAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) { ShowError("Import failed", ex); }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task RestartServiceAsync()
|
||||
{
|
||||
var ok = MessageBox.Show(
|
||||
"Restart the WebhookServer service? In-flight requests will be aborted.",
|
||||
"Restart service",
|
||||
MessageBoxButton.OKCancel,
|
||||
MessageBoxImage.Warning);
|
||||
if (ok != MessageBoxResult.OK) return;
|
||||
|
||||
try
|
||||
{
|
||||
await _client.RestartListenerAsync().ConfigureAwait(false);
|
||||
await Task.Delay(2000).ConfigureAwait(false);
|
||||
await RefreshAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ShowError("Restart failed", ex);
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void ShowAbout()
|
||||
{
|
||||
var dlg = new Views.AboutDialog { Owner = Application.Current.MainWindow };
|
||||
dlg.ShowDialog();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void OpenDocumentation()
|
||||
{
|
||||
try
|
||||
{
|
||||
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = "https://github.com/recklessop/webhook-server/tree/main/docs",
|
||||
UseShellExecute = true,
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ShowError("Could not open documentation", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <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();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void CopyEndpointUrl()
|
||||
{
|
||||
if (SelectedEndpoint is null || string.IsNullOrEmpty(HttpBaseUrl)) return;
|
||||
var url = $"{HttpBaseUrl.TrimEnd('/')}/hook/{SelectedEndpoint.Slug}";
|
||||
try
|
||||
{
|
||||
Clipboard.SetText(url);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ShowError("Copy failed", ex);
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task EditServerSettingsAsync()
|
||||
{
|
||||
var dlg = new ServerSettings { Owner = Application.Current.MainWindow };
|
||||
var vm = new ServerSettingsViewModel(ServerConfig);
|
||||
dlg.DataContext = vm;
|
||||
if (dlg.ShowDialog() != true) return;
|
||||
|
||||
try
|
||||
{
|
||||
ServerConfig.HttpPort = vm.HttpPort;
|
||||
ServerConfig.TrustedProxies = vm.TrustedProxiesList;
|
||||
ServerConfig.HttpsBinding = vm.BuildBinding();
|
||||
ServerConfig.BindAddresses = vm.BindAddressesList;
|
||||
ServerConfig.DisplayHost = vm.DisplayHostValue;
|
||||
await _client.InvokeAsync(AdminOps.UpdateConfig, ServerConfig).ConfigureAwait(false);
|
||||
await RefreshAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ShowError("Save failed", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ShowError(string title, Exception ex)
|
||||
{
|
||||
Application.Current.Dispatcher.Invoke(() =>
|
||||
MessageBox.Show(ex.Message, title, MessageBoxButton.OK, MessageBoxImage.Error));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Net;
|
||||
using System.Net.NetworkInformation;
|
||||
using System.Net.Sockets;
|
||||
using System.Runtime.Versioning;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using WebhookServer.Core.Models;
|
||||
|
||||
namespace WebhookServer.Gui.ViewModels;
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
public sealed partial class ServerSettingsViewModel : ObservableObject
|
||||
{
|
||||
[ObservableProperty] private int _httpPort;
|
||||
[ObservableProperty] private int _httpsPort;
|
||||
[ObservableProperty] private bool _httpsEnabled;
|
||||
[ObservableProperty] private string _httpsMode = "PfxFile";
|
||||
[ObservableProperty] private string _pfxPath = "";
|
||||
[ObservableProperty] private string _pfxPassword = "";
|
||||
[ObservableProperty] private string _thumbprint = "";
|
||||
[ObservableProperty] private string _trustedProxiesText = "";
|
||||
[ObservableProperty] private bool _listenAllInterfaces = true;
|
||||
[ObservableProperty] private string _displayHost = "localhost";
|
||||
|
||||
/// <summary>One row per detected local IPv4/IPv6 address. Bound for "listen on" checkboxes.</summary>
|
||||
public ObservableCollection<NetworkAddressRow> Addresses { get; } = new();
|
||||
|
||||
/// <summary>Suggestions for the Display URL host dropdown (detected IPs + localhost + machine name).</summary>
|
||||
public ObservableCollection<string> DisplayHostChoices { get; } = new();
|
||||
|
||||
public bool Accepted { get; private set; }
|
||||
|
||||
public ServerSettingsViewModel(ServerConfig config)
|
||||
{
|
||||
HttpPort = config.HttpPort;
|
||||
TrustedProxiesText = string.Join(Environment.NewLine, config.TrustedProxies);
|
||||
|
||||
var b = config.HttpsBinding;
|
||||
HttpsEnabled = b is not null && b.Kind != HttpsBindingKind.None;
|
||||
HttpsPort = b?.Port ?? 8443;
|
||||
HttpsMode = b?.Kind == HttpsBindingKind.CertStoreThumbprint ? "Thumbprint" : "PfxFile";
|
||||
PfxPath = b?.PfxPath ?? "";
|
||||
PfxPassword = b?.PfxPassword?.Plaintext ?? "";
|
||||
Thumbprint = b?.Thumbprint ?? "";
|
||||
|
||||
var detected = DetectLocalAddresses();
|
||||
var alreadyBound = new HashSet<string>(config.BindAddresses, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
ListenAllInterfaces = config.BindAddresses.Count == 0;
|
||||
foreach (var (addr, label) in detected)
|
||||
{
|
||||
Addresses.Add(new NetworkAddressRow
|
||||
{
|
||||
Address = addr,
|
||||
Label = label,
|
||||
IsBound = !ListenAllInterfaces && alreadyBound.Contains(addr),
|
||||
});
|
||||
}
|
||||
// Surface any persisted address that isn't currently detected (e.g. a NIC unplugged
|
||||
// since save) so the user can keep or remove it explicitly.
|
||||
foreach (var entry in config.BindAddresses)
|
||||
{
|
||||
if (Addresses.Any(a => string.Equals(a.Address, entry, StringComparison.OrdinalIgnoreCase))) continue;
|
||||
Addresses.Add(new NetworkAddressRow { Address = entry, Label = "(not currently present)", IsBound = true });
|
||||
}
|
||||
|
||||
DisplayHostChoices.Add("localhost");
|
||||
DisplayHostChoices.Add(Environment.MachineName);
|
||||
foreach (var (addr, _) in detected)
|
||||
if (!DisplayHostChoices.Contains(addr))
|
||||
DisplayHostChoices.Add(addr);
|
||||
|
||||
DisplayHost = string.IsNullOrEmpty(config.DisplayHost) ? "localhost" : config.DisplayHost;
|
||||
}
|
||||
|
||||
public List<string> TrustedProxiesList =>
|
||||
(TrustedProxiesText ?? "").Split(new[] { '\r', '\n', ',' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
|
||||
|
||||
public List<string> BindAddressesList =>
|
||||
ListenAllInterfaces
|
||||
? new List<string>()
|
||||
: Addresses.Where(a => a.IsBound).Select(a => a.Address).ToList();
|
||||
|
||||
public string? DisplayHostValue =>
|
||||
string.IsNullOrEmpty(DisplayHost) || DisplayHost == "localhost" ? null : DisplayHost.Trim();
|
||||
|
||||
public HttpsBinding? BuildBinding()
|
||||
{
|
||||
if (!HttpsEnabled) return null;
|
||||
|
||||
var binding = new HttpsBinding { Port = HttpsPort };
|
||||
if (string.Equals(HttpsMode, "Thumbprint", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
binding.Kind = HttpsBindingKind.CertStoreThumbprint;
|
||||
binding.Thumbprint = Thumbprint?.Trim();
|
||||
}
|
||||
else
|
||||
{
|
||||
binding.Kind = HttpsBindingKind.PfxFile;
|
||||
binding.PfxPath = PfxPath;
|
||||
if (!string.IsNullOrEmpty(PfxPassword))
|
||||
binding.PfxPassword = ProtectedString.FromPlaintext(PfxPassword);
|
||||
}
|
||||
return binding;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Save() => Accepted = true;
|
||||
|
||||
private static IEnumerable<(string Address, string Label)> DetectLocalAddresses()
|
||||
{
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var ni in NetworkInterface.GetAllNetworkInterfaces())
|
||||
{
|
||||
if (ni.OperationalStatus != OperationalStatus.Up) continue;
|
||||
if (ni.NetworkInterfaceType == NetworkInterfaceType.Tunnel) continue;
|
||||
foreach (var ua in ni.GetIPProperties().UnicastAddresses)
|
||||
{
|
||||
if (ua.Address.AddressFamily != AddressFamily.InterNetwork &&
|
||||
ua.Address.AddressFamily != AddressFamily.InterNetworkV6) continue;
|
||||
var key = ua.Address.ToString();
|
||||
if (!seen.Add(key)) continue;
|
||||
yield return (key, $"{ni.Name} ({ni.NetworkInterfaceType})");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed partial class NetworkAddressRow : ObservableObject
|
||||
{
|
||||
public required string Address { get; init; }
|
||||
public required string Label { get; init; }
|
||||
[ObservableProperty] private bool _isBound;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<Window x:Class="WebhookServer.Gui.Views.AboutDialog"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="About Webhook Server"
|
||||
Height="360" Width="440"
|
||||
ResizeMode="NoResize"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
Icon="/webhook-server.ico"
|
||||
ShowInTaskbar="False">
|
||||
<Grid Margin="20">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<TextBlock Grid.Row="0" Text="Webhook Server" FontSize="22" FontWeight="Bold"/>
|
||||
<TextBlock Grid.Row="1" x:Name="VersionText" Foreground="Gray" Margin="0,4,0,16"/>
|
||||
|
||||
<TextBlock Grid.Row="2" TextWrapping="Wrap">
|
||||
A Windows-native webhook server that runs PowerShell, cmd, or arbitrary
|
||||
executables in response to incoming HTTP requests.
|
||||
</TextBlock>
|
||||
|
||||
<StackPanel Grid.Row="3" Margin="0,16,0,0">
|
||||
<TextBlock>
|
||||
<Run Text="Created by"/>
|
||||
<Run Text="Justin Paul" FontWeight="SemiBold"/>
|
||||
</TextBlock>
|
||||
<TextBlock Margin="0,4,0,0">
|
||||
<Hyperlink NavigateUri="https://jpaul.me" RequestNavigate="OnHyperlink">https://jpaul.me</Hyperlink>
|
||||
</TextBlock>
|
||||
<TextBlock Margin="0,4,0,0">
|
||||
<Hyperlink NavigateUri="https://github.com/recklessop/webhook-server" RequestNavigate="OnHyperlink">github.com/recklessop/webhook-server</Hyperlink>
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
|
||||
<Button Grid.Row="5" Content="OK" Width="80" HorizontalAlignment="Right" IsDefault="True" IsCancel="True" Click="OnOk"/>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,28 @@
|
||||
using System.Diagnostics;
|
||||
using System.Reflection;
|
||||
using System.Windows;
|
||||
using System.Windows.Navigation;
|
||||
|
||||
namespace WebhookServer.Gui.Views;
|
||||
|
||||
public partial class AboutDialog : Window
|
||||
{
|
||||
public AboutDialog()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
var asm = Assembly.GetExecutingAssembly();
|
||||
var info = asm.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion
|
||||
?? asm.GetName().Version?.ToString()
|
||||
?? "0.0.0";
|
||||
VersionText.Text = $"Version {info}";
|
||||
}
|
||||
|
||||
private void OnHyperlink(object sender, RequestNavigateEventArgs e)
|
||||
{
|
||||
Process.Start(new ProcessStartInfo(e.Uri.AbsoluteUri) { UseShellExecute = true });
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void OnOk(object sender, RoutedEventArgs e) => Close();
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<Window x:Class="WebhookServer.Gui.Views.ConfigCheckpointsDialog"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:vm="clr-namespace:WebhookServer.Gui.ViewModels"
|
||||
mc:Ignorable="d"
|
||||
Title="Config Checkpoints"
|
||||
Height="500" Width="640"
|
||||
Icon="/webhook-server.ico"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
d:DataContext="{d:DesignInstance Type=vm:ConfigCheckpointsViewModel}">
|
||||
<DockPanel Margin="12">
|
||||
<TextBlock DockPanel.Dock="Top" TextWrapping="Wrap" Margin="0,0,0,8" Foreground="#444">
|
||||
A checkpoint is a snapshot of <Bold>config.json</Bold> taken before each save and once a day at midnight.
|
||||
Pick one and click <Bold>Roll Back</Bold> to restore it. The current configuration is automatically saved
|
||||
as a new checkpoint before any rollback, so you can always roll forward again.
|
||||
</TextBlock>
|
||||
|
||||
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,8,0,0">
|
||||
<Button Content="Take Checkpoint Now" Command="{Binding TakeCheckpointCommand}" Margin="0,0,8,0" Padding="10,4"/>
|
||||
<Button Content="Roll Back" Command="{Binding RollbackCommand}"
|
||||
IsEnabled="{Binding Selected, Converter={StaticResource NotNull}}"
|
||||
Margin="0,0,8,0" Padding="10,4"/>
|
||||
<Button Content="Close" IsCancel="True" Click="OnClose" Padding="10,4"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" Margin="0,4,0,0">
|
||||
<Button Content="Refresh" Command="{Binding RefreshCommand}" Padding="8,2"/>
|
||||
<TextBlock Text="{Binding StatusMessage}" Foreground="Gray" FontStyle="Italic" VerticalAlignment="Center" Margin="12,0,0,0"/>
|
||||
</StackPanel>
|
||||
|
||||
<DataGrid ItemsSource="{Binding Checkpoints}"
|
||||
SelectedItem="{Binding Selected, Mode=TwoWay}"
|
||||
AutoGenerateColumns="False"
|
||||
CanUserAddRows="False"
|
||||
CanUserDeleteRows="False"
|
||||
IsReadOnly="True"
|
||||
HeadersVisibility="Column"
|
||||
GridLinesVisibility="Horizontal">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="When (local)" Width="170"
|
||||
Binding="{Binding SavedAt, StringFormat='{}{0:yyyy-MM-dd HH:mm:ss}', ConverterCulture=en-US}"/>
|
||||
<DataGridTextColumn Header="Description" Width="*"
|
||||
Binding="{Binding Description}"/>
|
||||
<DataGridTextColumn Header="Size" Width="100"
|
||||
Binding="{Binding SizeBytes, StringFormat='{}{0:n0} bytes'}"/>
|
||||
<DataGridTextColumn Header="File name" Width="200"
|
||||
Binding="{Binding FileName}" FontFamily="Consolas"/>
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
</DockPanel>
|
||||
</Window>
|
||||
@@ -0,0 +1,19 @@
|
||||
using System.Windows;
|
||||
using WebhookServer.Gui.ViewModels;
|
||||
|
||||
namespace WebhookServer.Gui.Views;
|
||||
|
||||
public partial class ConfigCheckpointsDialog : Window
|
||||
{
|
||||
public ConfigCheckpointsDialog()
|
||||
{
|
||||
InitializeComponent();
|
||||
Loaded += async (_, _) =>
|
||||
{
|
||||
if (DataContext is ConfigCheckpointsViewModel vm)
|
||||
await vm.RefreshAsync();
|
||||
};
|
||||
}
|
||||
|
||||
private void OnClose(object sender, RoutedEventArgs e) => Close();
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
<Window x:Class="WebhookServer.Gui.Views.EndpointEditor"
|
||||
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="Endpoint" Height="700" Width="640"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
d:DataContext="{d:DesignInstance Type=vm:EndpointEditorViewModel}">
|
||||
<DockPanel Margin="12">
|
||||
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0">
|
||||
<Button Content="Save" Width="80" Margin="0,0,8,0" IsDefault="True" Click="OnSave" />
|
||||
<Button Content="Cancel" Width="80" IsCancel="True"/>
|
||||
</StackPanel>
|
||||
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel>
|
||||
<GroupBox Header="Identity" Padding="6">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="120"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition/>
|
||||
<RowDefinition/>
|
||||
<RowDefinition/>
|
||||
</Grid.RowDefinitions>
|
||||
<TextBlock Text="Slug" VerticalAlignment="Center"/>
|
||||
<TextBox Grid.Column="1" Text="{Binding Endpoint.Slug, UpdateSourceTrigger=PropertyChanged}"/>
|
||||
|
||||
<TextBlock Grid.Row="1" Text="Description" VerticalAlignment="Center" Margin="0,4,0,0"/>
|
||||
<TextBox Grid.Row="1" Grid.Column="1" Text="{Binding Endpoint.Description, UpdateSourceTrigger=PropertyChanged}" Margin="0,4,0,0"/>
|
||||
|
||||
<TextBlock Grid.Row="2" Text="Enabled" VerticalAlignment="Center" Margin="0,4,0,0"/>
|
||||
<CheckBox Grid.Row="2" Grid.Column="1" IsChecked="{Binding Endpoint.Enabled}" VerticalAlignment="Center" Margin="0,4,0,0"/>
|
||||
</Grid>
|
||||
</GroupBox>
|
||||
|
||||
<GroupBox Header="Auth" Padding="6" Margin="0,8,0,0">
|
||||
<StackPanel>
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="120"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Text="Mode" VerticalAlignment="Center"/>
|
||||
<ComboBox Grid.Column="1" ItemsSource="{Binding AuthModes}"
|
||||
SelectedItem="{Binding SelectedAuthMode, Mode=TwoWay}"/>
|
||||
</Grid>
|
||||
<Grid Margin="0,4,0,0" Visibility="{Binding BearerVisible}">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="120"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Text="Bearer secret" VerticalAlignment="Center"/>
|
||||
<TextBox Grid.Column="1" Text="{Binding BearerSecret, UpdateSourceTrigger=PropertyChanged}" FontFamily="Consolas"/>
|
||||
<Button Grid.Column="2" Content="Copy" Margin="4,0,0,0" Padding="6,0" Click="OnCopyBearer"/>
|
||||
</Grid>
|
||||
<StackPanel Visibility="{Binding HmacVisible}">
|
||||
<Grid Margin="0,4,0,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="120"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Text="HMAC secret" VerticalAlignment="Center"/>
|
||||
<TextBox Grid.Column="1" Text="{Binding HmacSecret, UpdateSourceTrigger=PropertyChanged}" FontFamily="Consolas"/>
|
||||
<Button Grid.Column="2" Content="Copy" Margin="4,0,0,0" Padding="6,0" Click="OnCopyHmac"/>
|
||||
</Grid>
|
||||
<Grid Margin="0,4,0,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="120"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Text="HMAC header" VerticalAlignment="Center"/>
|
||||
<TextBox Grid.Column="1" Text="{Binding Endpoint.Hmac.HeaderName, UpdateSourceTrigger=PropertyChanged}"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</GroupBox>
|
||||
|
||||
<GroupBox Header="IP allowlist (one per line, IP or CIDR)" Padding="6" Margin="0,8,0,0">
|
||||
<TextBox Text="{Binding AllowedClientsText, UpdateSourceTrigger=LostFocus}" AcceptsReturn="True" MinHeight="60" TextWrapping="Wrap" VerticalScrollBarVisibility="Auto"/>
|
||||
</GroupBox>
|
||||
|
||||
<GroupBox Header="Executor" Padding="6" Margin="0,8,0,0">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="120"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition/>
|
||||
<RowDefinition/>
|
||||
<RowDefinition/>
|
||||
<RowDefinition/>
|
||||
<RowDefinition/>
|
||||
<RowDefinition/>
|
||||
</Grid.RowDefinitions>
|
||||
<TextBlock Text="Type" VerticalAlignment="Center"/>
|
||||
<ComboBox Grid.Column="1" ItemsSource="{Binding ExecutorTypes}" SelectedItem="{Binding Endpoint.ExecutorType, Mode=TwoWay}"/>
|
||||
|
||||
<TextBlock Grid.Row="1" Text="Script path" VerticalAlignment="Center" Margin="0,4,0,0"/>
|
||||
<TextBox Grid.Row="1" Grid.Column="1" Text="{Binding Endpoint.ScriptPath, UpdateSourceTrigger=PropertyChanged}" Margin="0,4,0,0"/>
|
||||
|
||||
<TextBlock Grid.Row="2" Text="Inline command" VerticalAlignment="Center" Margin="0,4,0,0"/>
|
||||
<TextBox Grid.Row="2" Grid.Column="1" Text="{Binding Endpoint.InlineCommand, UpdateSourceTrigger=PropertyChanged}" Margin="0,4,0,0" AcceptsReturn="True" MinHeight="40"/>
|
||||
|
||||
<TextBlock Grid.Row="3" Text="Executable" VerticalAlignment="Center" Margin="0,4,0,0"/>
|
||||
<TextBox Grid.Row="3" Grid.Column="1" Text="{Binding Endpoint.ExecutablePath, UpdateSourceTrigger=PropertyChanged}" Margin="0,4,0,0"/>
|
||||
|
||||
<TextBlock Grid.Row="4" Text="Static args" VerticalAlignment="Center" Margin="0,4,0,0"/>
|
||||
<TextBox Grid.Row="4" Grid.Column="1" Text="{Binding ExecutableArgsText, UpdateSourceTrigger=LostFocus}" Margin="0,4,0,0"/>
|
||||
|
||||
<TextBlock Grid.Row="5" Text="Working dir" VerticalAlignment="Center" Margin="0,4,0,0"/>
|
||||
<TextBox Grid.Row="5" Grid.Column="1" Text="{Binding Endpoint.WorkingDirectory, UpdateSourceTrigger=PropertyChanged}" Margin="0,4,0,0"/>
|
||||
</Grid>
|
||||
</GroupBox>
|
||||
|
||||
<GroupBox Header="Data passing" Padding="6" Margin="0,8,0,0">
|
||||
<StackPanel>
|
||||
<CheckBox Content="JSON body to stdin" IsChecked="{Binding Endpoint.DataPassing.StdinJson}"/>
|
||||
<CheckBox Content="Headers/query as env vars (WEBHOOK_HEADER_*, WEBHOOK_QUERY_*)" IsChecked="{Binding Endpoint.DataPassing.EnvVars}" Margin="0,4,0,0"/>
|
||||
<CheckBox Content="Argument template" IsChecked="{Binding Endpoint.DataPassing.ArgTemplate}" Margin="0,4,0,0"/>
|
||||
<TextBox Text="{Binding Endpoint.DataPassing.ArgTemplateString, UpdateSourceTrigger=PropertyChanged}" Margin="0,4,0,0" />
|
||||
<TextBlock Text="Tokens: {{body.foo}} {{header.X-Foo}} {{query.bar}} {{route.slug}}" Foreground="Gray" Margin="0,2,0,0"/>
|
||||
</StackPanel>
|
||||
</GroupBox>
|
||||
|
||||
<GroupBox Header="Run as" Padding="6" Margin="0,8,0,0">
|
||||
<StackPanel>
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="120"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Text="Identity" VerticalAlignment="Center"/>
|
||||
<ComboBox Grid.Column="1" ItemsSource="{Binding RunAsModes}" SelectedItem="{Binding SelectedRunAsMode, Mode=TwoWay}"/>
|
||||
</Grid>
|
||||
<TextBlock Foreground="Gray" FontStyle="Italic" FontSize="11" Margin="120,2,0,0"
|
||||
Text="Service = run as the service account (default). InteractiveUser = the logged-in user's desktop session, lets hooks pop UI. SpecificUser = a named account with password."/>
|
||||
<StackPanel Visibility="{Binding SpecificUserVisible}">
|
||||
<Grid Margin="0,6,0,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="120"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Text="Username" VerticalAlignment="Center"/>
|
||||
<TextBox Grid.Column="1" Text="{Binding RunAsUsername, UpdateSourceTrigger=PropertyChanged}" FontFamily="Consolas"/>
|
||||
</Grid>
|
||||
<TextBlock Foreground="Gray" FontStyle="Italic" FontSize="11" Margin="120,2,0,0"
|
||||
Text="Examples: DOMAIN\justin .\local-user user@contoso.com"/>
|
||||
<Grid Margin="0,4,0,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="120"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Text="Password" VerticalAlignment="Center"/>
|
||||
<TextBox Grid.Column="1" Text="{Binding RunAsPassword, UpdateSourceTrigger=PropertyChanged}" FontFamily="Consolas"/>
|
||||
</Grid>
|
||||
<Grid Margin="0,4,0,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="120"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Text="Load profile" VerticalAlignment="Center"/>
|
||||
<CheckBox Grid.Column="1" IsChecked="{Binding RunAsLoadProfile}" VerticalAlignment="Center"
|
||||
Content="Load the user's HKCU + AppData (slower; only needed if the script reads user-scoped state)"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</GroupBox>
|
||||
|
||||
<GroupBox Header="Response" Padding="6" Margin="0,8,0,0">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="120"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition/>
|
||||
<RowDefinition/>
|
||||
<RowDefinition/>
|
||||
<RowDefinition/>
|
||||
</Grid.RowDefinitions>
|
||||
<TextBlock Text="Mode" VerticalAlignment="Center"/>
|
||||
<ComboBox Grid.Column="1" ItemsSource="{Binding ResponseModes}" SelectedItem="{Binding Endpoint.ResponseMode, Mode=TwoWay}"/>
|
||||
|
||||
<TextBlock Grid.Row="1" Text="Timeout (sec)" VerticalAlignment="Center" Margin="0,4,0,0"/>
|
||||
<TextBox Grid.Row="1" Grid.Column="1" Text="{Binding Endpoint.TimeoutSeconds, UpdateSourceTrigger=PropertyChanged}" Margin="0,4,0,0"/>
|
||||
|
||||
<TextBlock Grid.Row="2" Text="Fail on non-zero exit" VerticalAlignment="Center" Margin="0,4,0,0"/>
|
||||
<CheckBox Grid.Row="2" Grid.Column="1" IsChecked="{Binding Endpoint.FailOnNonZeroExit}" VerticalAlignment="Center" Margin="0,4,0,0"/>
|
||||
|
||||
<TextBlock Grid.Row="3" Text="Serialize runs" VerticalAlignment="Center" Margin="0,4,0,0"/>
|
||||
<CheckBox Grid.Row="3" Grid.Column="1" IsChecked="{Binding Endpoint.Serialize}" VerticalAlignment="Center" Margin="0,4,0,0"/>
|
||||
</Grid>
|
||||
</GroupBox>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</DockPanel>
|
||||
</Window>
|
||||
@@ -0,0 +1,33 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using WebhookServer.Gui.ViewModels;
|
||||
|
||||
namespace WebhookServer.Gui.Views;
|
||||
|
||||
public partial class EndpointEditor : Window
|
||||
{
|
||||
public EndpointEditor()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private void OnSave(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is EndpointEditorViewModel vm)
|
||||
vm.SaveCommand.Execute(null);
|
||||
DialogResult = true;
|
||||
Close();
|
||||
}
|
||||
|
||||
private void OnCopyBearer(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is EndpointEditorViewModel vm && !string.IsNullOrEmpty(vm.BearerSecret))
|
||||
try { Clipboard.SetText(vm.BearerSecret); } catch { /* clipboard busy — silent */ }
|
||||
}
|
||||
|
||||
private void OnCopyHmac(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is EndpointEditorViewModel vm && !string.IsNullOrEmpty(vm.HmacSecret))
|
||||
try { Clipboard.SetText(vm.HmacSecret); } catch { /* clipboard busy — silent */ }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
<Window x:Class="WebhookServer.Gui.Views.ServerSettings"
|
||||
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="Server Settings" Height="720" Width="600"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
d:DataContext="{d:DesignInstance Type=vm:ServerSettingsViewModel}">
|
||||
<DockPanel Margin="12">
|
||||
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0">
|
||||
<Button Content="Save" Width="80" Margin="0,0,8,0" IsDefault="True" Click="OnSave"/>
|
||||
<Button Content="Cancel" Width="80" IsCancel="True"/>
|
||||
</StackPanel>
|
||||
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel>
|
||||
<GroupBox Header="HTTP" Padding="6">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="160"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Text="HTTP port" VerticalAlignment="Center"/>
|
||||
<TextBox Grid.Column="1" Text="{Binding HttpPort, UpdateSourceTrigger=PropertyChanged}"/>
|
||||
</Grid>
|
||||
</GroupBox>
|
||||
|
||||
<GroupBox Header="Network" Padding="6" Margin="0,8,0,0">
|
||||
<StackPanel>
|
||||
<CheckBox Content="Listen on all interfaces (0.0.0.0 + ::)" IsChecked="{Binding ListenAllInterfaces}"/>
|
||||
<TextBlock Foreground="Gray" FontStyle="Italic" FontSize="11" Margin="20,2,0,0"
|
||||
Text="Uncheck to bind only to the addresses you select below."/>
|
||||
<ItemsControl ItemsSource="{Binding Addresses}" Margin="20,4,0,0"
|
||||
IsEnabled="{Binding ListenAllInterfaces, Converter={StaticResource InvertBool}}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<CheckBox IsChecked="{Binding IsBound}" Margin="0,1,0,1">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Text="{Binding Address}" FontFamily="Consolas" Width="220"/>
|
||||
<TextBlock Text="{Binding Label}" Foreground="Gray" Margin="8,0,0,0"/>
|
||||
</StackPanel>
|
||||
</CheckBox>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<Grid Margin="0,8,0,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="160"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Text="Display URL host" VerticalAlignment="Center"/>
|
||||
<ComboBox Grid.Column="1" IsEditable="True"
|
||||
ItemsSource="{Binding DisplayHostChoices}"
|
||||
Text="{Binding DisplayHost, UpdateSourceTrigger=PropertyChanged}"
|
||||
FontFamily="Consolas"/>
|
||||
</Grid>
|
||||
<TextBlock Foreground="Gray" FontStyle="Italic" FontSize="11" Margin="160,2,0,0"
|
||||
Text="Used in the URL column and Copy URL button. Cosmetic only - doesn't affect what the server actually accepts."/>
|
||||
</StackPanel>
|
||||
</GroupBox>
|
||||
|
||||
<GroupBox Header="HTTPS" Padding="6" Margin="0,8,0,0">
|
||||
<StackPanel>
|
||||
<CheckBox Content="Enabled" IsChecked="{Binding HttpsEnabled}"/>
|
||||
<Grid Margin="0,6,0,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="160"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition/>
|
||||
<RowDefinition/>
|
||||
<RowDefinition/>
|
||||
<RowDefinition/>
|
||||
<RowDefinition/>
|
||||
</Grid.RowDefinitions>
|
||||
<TextBlock Text="HTTPS port" VerticalAlignment="Center"/>
|
||||
<TextBox Grid.Column="1" Text="{Binding HttpsPort, UpdateSourceTrigger=PropertyChanged}"/>
|
||||
|
||||
<TextBlock Grid.Row="1" Text="Mode" VerticalAlignment="Center" Margin="0,4,0,0"/>
|
||||
<StackPanel Grid.Row="1" Grid.Column="1" Orientation="Horizontal" Margin="0,4,0,0">
|
||||
<RadioButton GroupName="HttpsMode" Content="PFX file"
|
||||
IsChecked="{Binding HttpsMode, Converter={StaticResource StringEqualsConverter}, ConverterParameter=PfxFile}"
|
||||
Tag="PfxFile" Checked="OnModeChecked"/>
|
||||
<RadioButton GroupName="HttpsMode" Content="Cert store thumbprint" Margin="12,0,0,0"
|
||||
IsChecked="{Binding HttpsMode, Converter={StaticResource StringEqualsConverter}, ConverterParameter=Thumbprint}"
|
||||
Tag="Thumbprint" Checked="OnModeChecked"/>
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Grid.Row="2" Text="PFX path" VerticalAlignment="Center" Margin="0,4,0,0"/>
|
||||
<TextBox Grid.Row="2" Grid.Column="1" Text="{Binding PfxPath, UpdateSourceTrigger=PropertyChanged}" Margin="0,4,0,0"/>
|
||||
|
||||
<TextBlock Grid.Row="3" Text="PFX password" VerticalAlignment="Center" Margin="0,4,0,0"/>
|
||||
<TextBox Grid.Row="3" Grid.Column="1" Text="{Binding PfxPassword, UpdateSourceTrigger=PropertyChanged}" Margin="0,4,0,0" FontFamily="Consolas"/>
|
||||
|
||||
<TextBlock Grid.Row="4" Text="Thumbprint" VerticalAlignment="Center" Margin="0,4,0,0"/>
|
||||
<TextBox Grid.Row="4" Grid.Column="1" Text="{Binding Thumbprint, UpdateSourceTrigger=PropertyChanged}" Margin="0,4,0,0"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</GroupBox>
|
||||
|
||||
<GroupBox Header="Trusted proxies (one per line, IP or CIDR)" Padding="6" Margin="0,8,0,0">
|
||||
<TextBox Text="{Binding TrustedProxiesText, UpdateSourceTrigger=LostFocus}" AcceptsReturn="True" MinHeight="80" TextWrapping="Wrap" VerticalScrollBarVisibility="Auto"/>
|
||||
</GroupBox>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</DockPanel>
|
||||
</Window>
|
||||
@@ -0,0 +1,27 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using WebhookServer.Gui.ViewModels;
|
||||
|
||||
namespace WebhookServer.Gui.Views;
|
||||
|
||||
public partial class ServerSettings : Window
|
||||
{
|
||||
public ServerSettings()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private void OnSave(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is ServerSettingsViewModel vm)
|
||||
vm.SaveCommand.Execute(null);
|
||||
DialogResult = true;
|
||||
Close();
|
||||
}
|
||||
|
||||
private void OnModeChecked(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is ServerSettingsViewModel vm && sender is RadioButton rb && rb.Tag is string tag)
|
||||
vm.HttpsMode = tag;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<Window x:Class="WebhookServer.Gui.Views.TakeCheckpointDialog"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="Take checkpoint"
|
||||
Height="180" Width="440"
|
||||
ResizeMode="NoResize"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
Icon="/webhook-server.ico"
|
||||
ShowInTaskbar="False">
|
||||
<Grid Margin="16">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<TextBlock Grid.Row="0" TextWrapping="Wrap"
|
||||
Text="Description for this checkpoint (optional):"/>
|
||||
<TextBox x:Name="DescriptionBox" Grid.Row="1" Margin="0,8,0,0" MaxLength="120">
|
||||
<TextBox.InputBindings>
|
||||
<KeyBinding Key="Enter" Command="{Binding OkCommand, ElementName=Self, FallbackValue={x:Null}}"/>
|
||||
</TextBox.InputBindings>
|
||||
</TextBox>
|
||||
<TextBlock Grid.Row="2" Foreground="Gray" FontStyle="Italic" FontSize="11" Margin="0,4,0,0"
|
||||
Text="Examples: 'Before adding new endpoint', 'Pre-AD-policy-change'. Leave blank to use 'Manual checkpoint'."/>
|
||||
|
||||
<StackPanel Grid.Row="4" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0">
|
||||
<Button Content="OK" Width="80" IsDefault="True" Click="OnOk" Margin="0,0,8,0"/>
|
||||
<Button Content="Cancel" Width="80" IsCancel="True" Click="OnCancel"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,27 @@
|
||||
using System.Windows;
|
||||
|
||||
namespace WebhookServer.Gui.Views;
|
||||
|
||||
public partial class TakeCheckpointDialog : Window
|
||||
{
|
||||
public string Description { get; private set; } = "";
|
||||
|
||||
public TakeCheckpointDialog()
|
||||
{
|
||||
InitializeComponent();
|
||||
Loaded += (_, _) => DescriptionBox.Focus();
|
||||
}
|
||||
|
||||
private void OnOk(object sender, RoutedEventArgs e)
|
||||
{
|
||||
Description = DescriptionBox.Text?.Trim() ?? "";
|
||||
DialogResult = true;
|
||||
Close();
|
||||
}
|
||||
|
||||
private void OnCancel(object sender, RoutedEventArgs e)
|
||||
{
|
||||
DialogResult = false;
|
||||
Close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\WebhookServer.Core\WebhookServer.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.3.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UseWPF>true</UseWPF>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<ApplicationIcon>..\..\resources\webhook-server.ico</ApplicationIcon>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<AssemblyTitle>Webhook Server</AssemblyTitle>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Resource Include="..\..\resources\webhook-server.ico" Link="webhook-server.ico" />
|
||||
<Resource Include="..\..\resources\webhook-server.png" Link="webhook-server.png" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<assemblyIdentity version="1.0.0.0" name="WebhookServer.Gui"/>
|
||||
|
||||
<!-- The GUI talks to the service via a named pipe ACL'd to SYSTEM and the
|
||||
Administrators group. UAC token splitting denies that group on the
|
||||
standard user token, so without elevation the pipe connect fails with
|
||||
"Access is denied". Always run elevated. Start Menu shortcuts and the
|
||||
installer's post-install launch both honor this. -->
|
||||
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<security>
|
||||
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v2">
|
||||
<requestedExecutionLevel level="requireAdministrator" uiAccess="false"/>
|
||||
</requestedPrivileges>
|
||||
</security>
|
||||
</trustInfo>
|
||||
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<!-- Windows 10 -->
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
|
||||
</application>
|
||||
</compatibility>
|
||||
</assembly>
|
||||
@@ -0,0 +1,423 @@
|
||||
using System.IO.Pipes;
|
||||
using System.Runtime.Versioning;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using WebhookServer.Core.Ipc;
|
||||
using WebhookServer.Core.Models;
|
||||
using WebhookServer.Core.Storage;
|
||||
|
||||
namespace WebhookServer.Service;
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
internal sealed class AdminPipeServer : BackgroundService
|
||||
{
|
||||
private readonly ServiceState _state;
|
||||
private readonly IHostApplicationLifetime _lifetime;
|
||||
private readonly ILogger<AdminPipeServer> _logger;
|
||||
|
||||
public AdminPipeServer(ServiceState state, IHostApplicationLifetime lifetime, ILogger<AdminPipeServer> logger)
|
||||
{
|
||||
_state = state;
|
||||
_lifetime = lifetime;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("Admin pipe server listening on \\\\.\\pipe\\{Pipe}", PipeSecurityFactory.PipeName);
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var pipe = NamedPipeServerStreamAcl.Create(
|
||||
PipeSecurityFactory.PipeName,
|
||||
PipeDirection.InOut,
|
||||
maxNumberOfServerInstances: 1,
|
||||
PipeTransmissionMode.Byte,
|
||||
PipeOptions.Asynchronous,
|
||||
inBufferSize: 0,
|
||||
outBufferSize: 0,
|
||||
PipeSecurityFactory.Create());
|
||||
|
||||
await pipe.WaitForConnectionAsync(stoppingToken).ConfigureAwait(false);
|
||||
await HandleClientAsync(pipe, stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Admin pipe accept loop error");
|
||||
try { await Task.Delay(500, stoppingToken).ConfigureAwait(false); }
|
||||
catch { break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleClientAsync(NamedPipeServerStream pipe, CancellationToken ct)
|
||||
{
|
||||
using var reader = PipeFraming.CreateReader(pipe);
|
||||
|
||||
while (pipe.IsConnected && !ct.IsCancellationRequested)
|
||||
{
|
||||
AdminRequest? request;
|
||||
try { request = await PipeFraming.ReadAsync<AdminRequest>(reader, ct).ConfigureAwait(false); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Admin pipe read error");
|
||||
break;
|
||||
}
|
||||
if (request is null) break;
|
||||
|
||||
AdminResponse response;
|
||||
try
|
||||
{
|
||||
response = await DispatchAsync(request, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Admin op {Op} failed", request.Op);
|
||||
response = AdminResponse.Failure(ex.Message);
|
||||
}
|
||||
|
||||
try { await PipeFraming.WriteAsync(pipe, response, ct).ConfigureAwait(false); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Admin pipe write error");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<AdminResponse> DispatchAsync(AdminRequest request, CancellationToken ct)
|
||||
{
|
||||
switch (request.Op)
|
||||
{
|
||||
case AdminOps.Ping:
|
||||
return AdminResponse.Success(new { pong = true, at = DateTimeOffset.UtcNow });
|
||||
|
||||
case AdminOps.GetStatus:
|
||||
{
|
||||
var snap = _state.Snapshot();
|
||||
return AdminResponse.Success(new StatusInfo
|
||||
{
|
||||
Running = true,
|
||||
HttpPort = snap.HttpPort,
|
||||
HttpsPort = snap.HttpsBinding?.Port,
|
||||
DisplayHost = snap.DisplayHost,
|
||||
StartedAt = _state.StartedAt,
|
||||
EndpointCount = snap.Endpoints.Count,
|
||||
});
|
||||
}
|
||||
|
||||
case AdminOps.GetConfig:
|
||||
{
|
||||
var snap = SafeSnapshotForWire(_state.Snapshot());
|
||||
return AdminResponse.Success(snap);
|
||||
}
|
||||
|
||||
case AdminOps.UpdateConfig:
|
||||
{
|
||||
var incoming = DeserializeData<ServerConfig>(request) ?? throw new ArgumentException("missing config payload");
|
||||
MergeWithExistingSecrets(incoming, _state.Snapshot());
|
||||
await _state.ReplaceAsync(incoming, ct).ConfigureAwait(false);
|
||||
_logger.LogInformation("Server config replaced ({Count} endpoints)", incoming.Endpoints.Count);
|
||||
return AdminResponse.Success(SafeSnapshotForWire(_state.Snapshot()));
|
||||
}
|
||||
|
||||
case AdminOps.ListEndpoints:
|
||||
return AdminResponse.Success(SafeSnapshotForWire(_state.Snapshot()).Endpoints);
|
||||
|
||||
case AdminOps.CreateEndpoint:
|
||||
{
|
||||
var ep = DeserializeData<EndpointConfig>(request) ?? throw new ArgumentException("missing endpoint");
|
||||
if (ep.Id == Guid.Empty) ep.Id = Guid.NewGuid();
|
||||
var next = CloneSnapshotForEdit();
|
||||
if (next.Endpoints.Any(e => string.Equals(e.Slug, ep.Slug, StringComparison.Ordinal)))
|
||||
return AdminResponse.Failure($"slug '{ep.Slug}' already exists");
|
||||
next.Endpoints.Add(ep);
|
||||
await _state.ReplaceAsync(next, ct).ConfigureAwait(false);
|
||||
_logger.LogInformation("Endpoint created: {Slug} ({Id})", ep.Slug, ep.Id);
|
||||
return AdminResponse.Success(ep);
|
||||
}
|
||||
|
||||
case AdminOps.UpdateEndpoint:
|
||||
{
|
||||
var ep = DeserializeData<EndpointConfig>(request) ?? throw new ArgumentException("missing endpoint");
|
||||
var next = CloneSnapshotForEdit();
|
||||
var idx = next.Endpoints.FindIndex(e => e.Id == ep.Id);
|
||||
if (idx < 0) return AdminResponse.Failure("endpoint not found");
|
||||
MergeEndpointSecrets(ep, next.Endpoints[idx]);
|
||||
next.Endpoints[idx] = ep;
|
||||
await _state.ReplaceAsync(next, ct).ConfigureAwait(false);
|
||||
_logger.LogInformation("Endpoint updated: {Slug} ({Id})", ep.Slug, ep.Id);
|
||||
return AdminResponse.Success(ep);
|
||||
}
|
||||
|
||||
case AdminOps.DeleteEndpoint:
|
||||
{
|
||||
var args = DeserializeData<DeleteEndpointArgs>(request) ?? throw new ArgumentException("missing id");
|
||||
var next = CloneSnapshotForEdit();
|
||||
var removed = next.Endpoints.RemoveAll(e => e.Id == args.Id);
|
||||
if (removed == 0) return AdminResponse.Failure("endpoint not found");
|
||||
await _state.ReplaceAsync(next, ct).ConfigureAwait(false);
|
||||
_logger.LogInformation("Endpoint deleted: {Id}", args.Id);
|
||||
return AdminResponse.Success();
|
||||
}
|
||||
|
||||
case AdminOps.EnableEndpoint:
|
||||
case AdminOps.DisableEndpoint:
|
||||
{
|
||||
var args = DeserializeData<EndpointToggle>(request) ?? throw new ArgumentException("missing id");
|
||||
var next = CloneSnapshotForEdit();
|
||||
var ep = next.Endpoints.FirstOrDefault(e => e.Id == args.Id);
|
||||
if (ep is null) return AdminResponse.Failure("endpoint not found");
|
||||
var newState = request.Op == AdminOps.EnableEndpoint;
|
||||
ep.Enabled = newState;
|
||||
await _state.ReplaceAsync(next, ct).ConfigureAwait(false);
|
||||
_logger.LogInformation("Endpoint {Slug} {State}", ep.Slug, newState ? "enabled" : "disabled");
|
||||
return AdminResponse.Success(ep);
|
||||
}
|
||||
|
||||
case AdminOps.BindHttps:
|
||||
{
|
||||
var binding = DeserializeData<HttpsBinding>(request);
|
||||
var next = CloneSnapshotForEdit();
|
||||
next.HttpsBinding = binding;
|
||||
await _state.ReplaceAsync(next, ct).ConfigureAwait(false);
|
||||
_logger.LogInformation("HTTPS binding {Action}",
|
||||
binding is null || binding.Kind == HttpsBindingKind.None ? "cleared" : $"set ({binding.Kind} on port {binding.Port})");
|
||||
return AdminResponse.Success();
|
||||
}
|
||||
|
||||
case AdminOps.RestartListener:
|
||||
_logger.LogInformation("Restart requested via admin pipe");
|
||||
_lifetime.StopApplication();
|
||||
return AdminResponse.Success();
|
||||
|
||||
case AdminOps.TailLogs:
|
||||
{
|
||||
var args = DeserializeData<TailLogsArgs>(request) ?? new TailLogsArgs();
|
||||
var lines = ReadTailLines(args.LinesToBacklog);
|
||||
return AdminResponse.Success(new { lines });
|
||||
}
|
||||
|
||||
case AdminOps.ListBackups:
|
||||
{
|
||||
var entries = ListBackups();
|
||||
return AdminResponse.Success(new { backups = entries });
|
||||
}
|
||||
|
||||
case AdminOps.RestoreBackup:
|
||||
{
|
||||
var args = DeserializeData<RestoreBackupArgs>(request) ?? throw new ArgumentException("missing fileName");
|
||||
var restored = await RestoreBackupAsync(args.FileName, ct).ConfigureAwait(false);
|
||||
_logger.LogInformation("Restored config from backup {File}", args.FileName);
|
||||
return AdminResponse.Success(SafeSnapshotForWire(restored));
|
||||
}
|
||||
|
||||
case AdminOps.ImportConfig:
|
||||
{
|
||||
var incoming = DeserializeData<ServerConfig>(request) ?? throw new ArgumentException("missing config payload");
|
||||
MergeWithExistingSecrets(incoming, _state.Snapshot());
|
||||
await _state.ReplaceAsync(incoming, ct).ConfigureAwait(false);
|
||||
_logger.LogInformation("Config imported ({Count} endpoints)", incoming.Endpoints.Count);
|
||||
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
|
||||
{
|
||||
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.
|
||||
if (fileName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0)
|
||||
throw new ArgumentException("invalid file name");
|
||||
var backupPath = Path.Combine(ServicePaths.DataRoot, "backups", fileName);
|
||||
if (!File.Exists(backupPath))
|
||||
throw new FileNotFoundException("backup not found", fileName);
|
||||
|
||||
await using var fs = File.OpenRead(backupPath);
|
||||
var cfg = await JsonSerializer.DeserializeAsync<ServerConfig>(fs, ConfigJson.Pretty, ct).ConfigureAwait(false)
|
||||
?? throw new InvalidOperationException("backup file was empty");
|
||||
await _state.ReplaceAsync(cfg, ct).ConfigureAwait(false);
|
||||
return _state.Snapshot();
|
||||
}
|
||||
|
||||
private ServerConfig CloneSnapshotForEdit()
|
||||
{
|
||||
// Round-trip via JSON to avoid sharing references with the live snapshot.
|
||||
var snap = _state.Snapshot();
|
||||
var json = JsonSerializer.Serialize(snap, ConfigJson.Compact);
|
||||
return JsonSerializer.Deserialize<ServerConfig>(json, ConfigJson.Compact)!;
|
||||
}
|
||||
|
||||
private static T? DeserializeData<T>(AdminRequest request)
|
||||
{
|
||||
if (request.Data is not { ValueKind: not JsonValueKind.Null and not JsonValueKind.Undefined } element)
|
||||
return default;
|
||||
return element.Deserialize<T>(AdminProtocol.JsonOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deep-clone the snapshot for the GUI. Plaintext secrets ARE included on the
|
||||
/// wire — the admin pipe is ACL'd to SYSTEM and Administrators, so anyone able
|
||||
/// to read the wire already has full local privilege. Letting the GUI display
|
||||
/// secrets means an admin can recover a lost token without resetting it.
|
||||
/// </summary>
|
||||
private static ServerConfig SafeSnapshotForWire(ServerConfig snap)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(snap, ConfigJson.Compact);
|
||||
return JsonSerializer.Deserialize<ServerConfig>(json, ConfigJson.Compact)!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When the GUI sends an <see cref="EndpointConfig"/> with empty plaintext on a
|
||||
/// secret, we keep the existing encrypted blob from disk. Without this, a GUI
|
||||
/// edit that doesn't touch the secret field would erase the secret.
|
||||
/// </summary>
|
||||
private static void MergeWithExistingSecrets(ServerConfig incoming, ServerConfig existing)
|
||||
{
|
||||
var byId = existing.Endpoints.ToDictionary(e => e.Id);
|
||||
foreach (var ep in incoming.Endpoints)
|
||||
{
|
||||
if (!byId.TryGetValue(ep.Id, out var prior)) continue;
|
||||
MergeEndpointSecrets(ep, prior);
|
||||
}
|
||||
|
||||
if (incoming.HttpsBinding is { } b && existing.HttpsBinding is { } prev)
|
||||
MergeProtected(b.PfxPassword, prev.PfxPassword);
|
||||
}
|
||||
|
||||
private static void MergeEndpointSecrets(EndpointConfig incoming, EndpointConfig prior)
|
||||
{
|
||||
if (incoming.Bearer is { } a) MergeProtected(a.Secret, prior.Bearer?.Secret);
|
||||
if (incoming.Hmac is { } h) MergeProtected(h.Secret, prior.Hmac?.Secret);
|
||||
if (incoming.RunAs is { Password: { } runAsPwd }) MergeProtected(runAsPwd, prior.RunAs?.Password);
|
||||
if (incoming.Callback is { } cb)
|
||||
{
|
||||
if (cb.Bearer is { } cba) MergeProtected(cba.Secret, prior.Callback?.Bearer?.Secret);
|
||||
if (cb.Hmac is { } cbh) MergeProtected(cbh.Secret, prior.Callback?.Hmac?.Secret);
|
||||
}
|
||||
}
|
||||
|
||||
private static void MergeProtected(ProtectedString? incoming, ProtectedString? prior)
|
||||
{
|
||||
if (incoming is null) return;
|
||||
if (!string.IsNullOrEmpty(incoming.Plaintext)) return; // GUI is supplying a new value
|
||||
if (string.IsNullOrEmpty(incoming.Encrypted) && prior is not null && !string.IsNullOrEmpty(prior.Encrypted))
|
||||
incoming.Encrypted = prior.Encrypted; // preserve previous secret
|
||||
}
|
||||
|
||||
private static List<LogLine> ReadTailLines(int count)
|
||||
{
|
||||
try
|
||||
{
|
||||
var dir = ServicePaths.LogsDir;
|
||||
if (!Directory.Exists(dir)) return new List<LogLine>();
|
||||
var latest = Directory.GetFiles(dir, "webhook-*.log")
|
||||
.OrderByDescending(p => p)
|
||||
.FirstOrDefault();
|
||||
if (latest is null) return new List<LogLine>();
|
||||
|
||||
using var fs = new FileStream(latest, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
|
||||
using var sr = new StreamReader(fs);
|
||||
var lines = new LinkedList<string>();
|
||||
while (sr.ReadLine() is { } line)
|
||||
{
|
||||
lines.AddLast(line);
|
||||
if (lines.Count > count) lines.RemoveFirst();
|
||||
}
|
||||
return lines.Select(l => new LogLine
|
||||
{
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
Level = "Information",
|
||||
Message = l,
|
||||
}).ToList();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new List<LogLine>();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using WebhookServer.Core.Callbacks;
|
||||
|
||||
namespace WebhookServer.Service;
|
||||
|
||||
internal sealed class CallbackBackgroundService : BackgroundService
|
||||
{
|
||||
private readonly CallbackDispatcher _dispatcher;
|
||||
|
||||
public CallbackBackgroundService(CallbackDispatcher dispatcher) => _dispatcher = dispatcher;
|
||||
|
||||
protected override Task ExecuteAsync(CancellationToken stoppingToken) =>
|
||||
_dispatcher.RunAsync(stoppingToken);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using System.Runtime.Versioning;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace WebhookServer.Service;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a daily config checkpoint at midnight (local time). Combined with
|
||||
/// the auto-on-save snapshots in ConfigStore.SaveAsync, this guarantees a
|
||||
/// rollback point for every day even if the user makes no changes.
|
||||
/// </summary>
|
||||
[SupportedOSPlatform("windows")]
|
||||
internal sealed class CheckpointScheduler : BackgroundService
|
||||
{
|
||||
private readonly ILogger<CheckpointScheduler> _logger;
|
||||
|
||||
public CheckpointScheduler(ILogger<CheckpointScheduler> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("Daily checkpoint scheduler running");
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
var now = DateTime.Now;
|
||||
var nextMidnight = now.Date.AddDays(1);
|
||||
var delay = nextMidnight - now;
|
||||
|
||||
try { await Task.Delay(delay, stoppingToken).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { return; }
|
||||
|
||||
try
|
||||
{
|
||||
var entry = AdminPipeServer.CreateCheckpoint("daily", "Nightly auto-checkpoint");
|
||||
_logger.LogInformation("Daily checkpoint created: {File}", entry.FileName);
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
// No config.json yet (fresh install, GUI never opened) - skip silently.
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Daily checkpoint creation failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
using System.Runtime.Versioning;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Serilog;
|
||||
using WebhookServer.Core.Callbacks;
|
||||
using WebhookServer.Core.Execution;
|
||||
using WebhookServer.Core.Models;
|
||||
using WebhookServer.Core.Storage;
|
||||
using WebhookServer.Service;
|
||||
|
||||
[assembly: SupportedOSPlatform("windows")]
|
||||
|
||||
Directory.CreateDirectory(ServicePaths.DataRoot);
|
||||
Directory.CreateDirectory(ServicePaths.LogsDir);
|
||||
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.MinimumLevel.Information()
|
||||
.Enrich.FromLogContext()
|
||||
.WriteTo.Async(a => a.File(
|
||||
ServicePaths.LogFileTemplate,
|
||||
rollingInterval: RollingInterval.Day,
|
||||
retainedFileCountLimit: 14,
|
||||
outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}"))
|
||||
.CreateLogger();
|
||||
|
||||
try
|
||||
{
|
||||
Log.Information("Starting WebhookServer.Service");
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Host.UseWindowsService(o => o.ServiceName = "WebhookServer");
|
||||
builder.Host.UseSerilog();
|
||||
|
||||
var configStore = new ConfigStore(ServicePaths.ConfigPath);
|
||||
var initialConfig = await configStore.LoadAsync().ConfigureAwait(false);
|
||||
ConfigStore.DecryptSecrets(initialConfig);
|
||||
|
||||
builder.WebHost.ConfigureKestrel(opts =>
|
||||
{
|
||||
ConfigureHttp(opts, initialConfig);
|
||||
ConfigureHttps(opts, initialConfig.HttpsBinding);
|
||||
});
|
||||
|
||||
builder.Services.AddSingleton(configStore);
|
||||
builder.Services.AddSingleton<ServiceState>();
|
||||
builder.Services.AddSingleton<IExecutor, ProcessExecutor>();
|
||||
builder.Services.AddSingleton<ConcurrencyGate>();
|
||||
builder.Services.AddSingleton<CallbackDispatcher>(sp =>
|
||||
new CallbackDispatcher(sp.GetService<Microsoft.Extensions.Logging.ILogger<CallbackDispatcher>>()));
|
||||
builder.Services.AddSingleton<WebhookRouter>();
|
||||
builder.Services.AddHostedService<CallbackBackgroundService>();
|
||||
builder.Services.AddHostedService<AdminPipeServer>();
|
||||
builder.Services.AddHostedService<CheckpointScheduler>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
var state = app.Services.GetRequiredService<ServiceState>();
|
||||
await state.LoadAsync().ConfigureAwait(false);
|
||||
|
||||
var lifetime = app.Services.GetRequiredService<IHostApplicationLifetime>();
|
||||
state.ListenerSettingsChanged += (_, _) =>
|
||||
{
|
||||
Log.Information("Listener settings changed; stopping service for restart.");
|
||||
lifetime.StopApplication();
|
||||
};
|
||||
|
||||
// Accept POST (the standard webhook verb) and GET (so a browser can smoke-test
|
||||
// hooks without curl). GET requests will have an empty body, which the executor
|
||||
// and arg-template renderer handle as if the body were empty JSON.
|
||||
app.MapMethods("/hook/{slug}", new[] { "GET", "POST" }, async (string slug, HttpContext http) =>
|
||||
{
|
||||
var router = http.RequestServices.GetRequiredService<WebhookRouter>();
|
||||
await router.HandleAsync(http, slug);
|
||||
});
|
||||
|
||||
app.MapGet("/healthz", () => Results.Ok(new { ok = true }));
|
||||
|
||||
// Stop browsers from logging 404s for favicon.ico every time they hit a hook.
|
||||
app.MapGet("/favicon.ico", () => Results.StatusCode(StatusCodes.Status204NoContent));
|
||||
|
||||
await app.RunAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Fatal(ex, "Service terminated unexpectedly");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Log.CloseAndFlush();
|
||||
}
|
||||
|
||||
static void ConfigureHttp(Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerOptions opts, ServerConfig cfg)
|
||||
{
|
||||
if (cfg.BindAddresses is { Count: > 0 } binds)
|
||||
{
|
||||
foreach (var entry in binds)
|
||||
{
|
||||
if (System.Net.IPAddress.TryParse(entry, out var ip))
|
||||
opts.Listen(ip, cfg.HttpPort);
|
||||
else
|
||||
Log.Warning("Skipping invalid bind address {Entry}", entry);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
opts.ListenAnyIP(cfg.HttpPort);
|
||||
}
|
||||
}
|
||||
|
||||
static void ConfigureHttps(Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerOptions opts, HttpsBinding? binding)
|
||||
{
|
||||
if (binding is null || binding.Kind == HttpsBindingKind.None) return;
|
||||
|
||||
X509Certificate2? cert = null;
|
||||
switch (binding.Kind)
|
||||
{
|
||||
case HttpsBindingKind.PfxFile:
|
||||
if (string.IsNullOrEmpty(binding.PfxPath)) return;
|
||||
var password = binding.PfxPassword?.Plaintext;
|
||||
cert = string.IsNullOrEmpty(password)
|
||||
? new X509Certificate2(binding.PfxPath)
|
||||
: new X509Certificate2(binding.PfxPath, password);
|
||||
break;
|
||||
case HttpsBindingKind.CertStoreThumbprint:
|
||||
if (string.IsNullOrEmpty(binding.Thumbprint)) return;
|
||||
using (var store = new X509Store(StoreName.My, binding.StoreLocation))
|
||||
{
|
||||
store.Open(OpenFlags.ReadOnly);
|
||||
var matches = store.Certificates.Find(X509FindType.FindByThumbprint, binding.Thumbprint, validOnly: false);
|
||||
if (matches.Count > 0) cert = matches[0];
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (cert is null)
|
||||
{
|
||||
Log.Warning("HTTPS binding configured but no certificate was loaded; HTTPS endpoint will not be enabled.");
|
||||
return;
|
||||
}
|
||||
|
||||
opts.ListenAnyIP(binding.Port, listen => listen.UseHttps(cert));
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace WebhookServer.Service;
|
||||
|
||||
/// <summary>
|
||||
/// Standard locations for runtime files (config + logs). Centralised so they're easy
|
||||
/// to override in tests and inspect in one place.
|
||||
/// </summary>
|
||||
public static class ServicePaths
|
||||
{
|
||||
public static string DataRoot { get; } =
|
||||
Environment.GetEnvironmentVariable("WEBHOOKSERVER_DATA")
|
||||
?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "WebhookServer");
|
||||
|
||||
public static string ConfigPath => Path.Combine(DataRoot, "config.json");
|
||||
public static string LogsDir => Path.Combine(DataRoot, "logs");
|
||||
public static string LogFileTemplate => Path.Combine(LogsDir, "webhook-.log");
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user