25 Commits

Author SHA1 Message Date
justin 73620e0065 Sync: ISCC cwd fix 2026-05-08 13:09:47 -04:00
justin d89290aedb Sync: installer diagnostics (#9) 2026-05-08 12:56:22 -04:00
justin ddd36a9116 Sync from GitHub main: v0.1.4 (#8) 2026-05-08 12:32:42 -04:00
justin b66dd245c0 Sync from GitHub main: Gitea Actions support (#7) 2026-05-08 12:02:59 -04:00
justin 1ea724cd1f Sync from GitHub main: v0.1.3 (#6)
CI / build (push) Has been cancelled
2026-05-08 11:32:20 -04:00
justin a2bd338839 Wiki sync: stop treating git's stderr as fatal (#5)
Sync Wiki / sync (push) Has been cancelled
CI / build (push) Has been cancelled
2026-05-08 11:21:07 -04:00
justin b17d832842 Sync from GitHub main: v0.1.1 + v0.1.2 + wiki sync (#3)
Sync Wiki / sync (push) Has been cancelled
CI / build (push) Has been cancelled
2026-05-08 11:14:17 -04:00
justin fe42f2f908 Merge pull request 'Document service account choices for AD-aware hooks' (#1) from claude/pensive-easley-4abcbe into main
CI / build (push) Has been cancelled
Reviewed-on: #1
2026-05-08 10:05:11 -04:00
justin 93a9c327e0 Phase 4: backups + import/export config
Release / build-installer (push) Has been cancelled
CI / build (pull_request) Has been cancelled
ConfigStore.SaveAsync now snapshots the previous config to
%ProgramData%\WebhookServer\backups\config-<timestamp>.json before
overwriting, retaining the last 30. Failures are silent so a
backup-write hiccup never blocks an actual save.

Three new admin pipe ops:
- list-backups: returns newest 50 entries with timestamps and sizes
- restore-backup: takes a fileName, refuses path-traversal chars,
  loads the named backup over the live config (which itself triggers
  a fresh backup of the current state via the SaveAsync hook)
- import-config: replaces the current config with a GUI-supplied
  ServerConfig, merging encrypted secrets where the GUI didn't supply
  new plaintext

GUI File menu items are wired:
- Import config: file picker -> ImportConfigAsync
- Export config: SaveFileDialog writes the current config as JSON
- Backups: dynamic submenu auto-refreshed when opened, listing
  backups with timestamp + size; click to confirm-and-restore

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 09:55:03 -04:00
justin 9e6abeef74 Phase 6+7: Inno Setup installer + GitHub Actions release pipeline
CI / build (pull_request) Has been cancelled
installer/webhook-server.iss is an Inno Setup 6 script that:
- Installs to %ProgramFiles%\WebhookServer
- Creates Start Menu folder + GUI shortcut (and optional desktop icon)
- Runs install-service.ps1 post-install to register the Windows Service
- Runs uninstall-service.ps1 pre-uninstall to remove it
- Bundles the webhook-server icon for the installer / uninstaller

scripts/build-installer.ps1 is the local build helper: publishes both
projects, finds ISCC.exe (PATH or standard install path), compiles the
installer with the version pulled from Directory.Build.props, drops the
output in dist/.

.github/workflows/ci.yml runs build + test on every push/PR to main.
.github/workflows/release.yml triggers on v* tags (or manual dispatch),
runs tests, installs Inno Setup via choco, builds the installer, and
attaches the .exe to a GitHub Release. Pre-1.0 versions are flagged
prerelease automatically.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 09:52:37 -04:00
justin 9525ee358e Phase 5: tray icon with minimize-to-tray and context menu
GUI csproj enables UseWindowsForms (NotifyIcon lives in WinForms even
in .NET 8). New Services/TrayIcon.cs wraps NotifyIcon with a context
menu (Open / Restart service / Exit) and the embedded webhook-server
icon. MainWindow creates the TrayIcon, hides itself on minimize and
restores on tray double-click.

Adds GlobalUsings.cs to alias the WPF defaults for types that exist
in both WPF and WinForms (Application, MessageBox, TextBox, Binding,
etc.) so existing code keeps compiling without per-file changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 09:51:00 -04:00
justin f3bca1e8ff Phase 3: app icon (multi-resolution ICO + master PNG)
scripts/generate-icons.ps1 renders the icon programmatically with
System.Drawing - rounded teal square (#0E7C66) with a stylized white
hook glyph - at 16/24/32/48/64/128/256 px and assembles a proper
multi-resolution Microsoft ICO. The PNG and ICO outputs land in
resources/. The script is the source of truth; re-run after editing
the design.

GUI csproj uses ApplicationIcon for the EXE icon and embeds the .ico
+ .png as Resources so MainWindow and AboutDialog can use them via
WPF's resource URI scheme.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 09:48:33 -04:00
justin a45d994c18 Phase 1: versioning, menu bar, About dialog, right-click context menu
- Directory.Build.props sets Version=0.1.0 (semver pre-1.0 = beta) plus
  Authors / Product / RepositoryUrl, picked up by all three projects.
- MainWindow gets a real menu bar (File / Server / Help) replacing the
  old toolbar. File: New endpoint / Import / Export / Backups (last
  three are stubs for the next phase) / Exit. Server: Settings /
  Restart service. Help: About.
- Drop the Refresh button - the 3 s polling loop covers it.
- DataGridRow gets a right-click context menu: Edit / Copy URL /
  toggle Enabled / Delete.
- New About dialog reads AssemblyInformationalVersion at runtime and
  links jpaul.me + the GitHub repo via clickable hyperlinks.
- Ctrl+N input binding for new-endpoint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 09:39:18 -04:00
justin 28479272d5 Configurable bind addresses + display host in Server Settings
ServerConfig grows two fields:
- BindAddresses: list of IPs Kestrel binds to (empty = all interfaces,
  current behavior). Listening only on a subset is useful when the host
  has multiple NICs and the webhook should not be reachable on all of
  them.
- DisplayHost: the hostname/IP the GUI splices into the URL column and
  Copy URL button. Cosmetic; doesn't affect what the server accepts.

Server Settings dialog gains a "Network" section: a checkbox for "all
interfaces" plus per-NIC checkboxes auto-detected via NetworkInterface.
GetAllNetworkInterfaces, and an editable ComboBox for the display host
pre-populated with detected IPs and the machine name.

Listener restart fires on BindAddresses change but not on DisplayHost
change (cosmetic).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 09:27:18 -04:00
justin 4ef8d20578 Skip lpDesktop=winsta0\default for SpecificUser launches
Setting lpDesktop on STARTUPINFO forces the child to open that desktop;
the LogonUser-derived token in SpecificUser mode usually cannot, since
winsta0\default's DACL only grants the currently-logged-in user. The
result was STATUS_DLL_INIT_FAILED (exit 0xC0000142) with empty stdio.

Only InteractiveUser mode needs the explicit interactive desktop -
that whole point of the mode is to land in the user's session. For
SpecificUser, leaving lpDesktop null lets the child inherit our
service desktop, which works for headless batch tasks (AD reads, file
ops, etc.).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 09:18:59 -04:00
justin 8b855ec9b9 Route SpecificUser through LogonUser+CreateProcessAsUser, not psi.UserName
CreateProcessWithLogonW (which ProcessStartInfo.UserName/Password uses
under the hood) refuses to run when the caller is LocalSystem - which
is exactly the scenario every hook hits, since the service runs as
SYSTEM by default. The hook just got "Access is denied" with no useful
context.

Switch SpecificUser to the same LogonUser + DuplicateTokenEx +
CreateProcessAsUser path that InteractiveUser already uses. The
launcher tries LOGON32_LOGON_INTERACTIVE first, falling back to
LOGON32_LOGON_BATCH for accounts without interactive-logon rights
(typical for service-only users). Domain "." is normalized to the
machine name so ".\justin" works.

The launcher's two public entry points - LaunchAsActiveConsoleUser
and LaunchAsSpecificUser - share the same LaunchWithToken core, so
stdio capture and environment-block construction stay identical.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 09:17:07 -04:00
justin 1e48b8185b Log execution outcomes (success / failure / timeout / launch error)
Hook runs were silently dropping their result into the void after
returning the HTTP response. For sync runs the body went to the
caller but nothing was logged; for async runs the result vanished
unless a callback was configured. That made debugging RunAs
failures (logon errors, missing executables) effectively
impossible since the service log only showed the 202.

Now every run emits one log line at INF (success) or WRN
(non-zero exit / timeout / launch error) with runId, slug, exit
code, duration, and truncated stdout/stderr.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 09:12:35 -04:00
justin 24d8701b65 Per-endpoint RunAs: Service / InteractiveUser / SpecificUser
Native per-endpoint identity instead of the schtasks bridge:
- Service (default) keeps the existing path - hooks inherit the service
  account (SYSTEM by default, or whatever you installed under).
- SpecificUser binds ProcessStartInfo.UserName / Password / Domain so
  the hook runs in a batch logon session as the named account. Useful
  for AD-write hooks that should NOT run as SYSTEM.
- InteractiveUser uses WTSQueryUserToken(WTSGetActiveConsoleSessionId)
  + DuplicateTokenEx + CreateProcessAsUser to drop the child into the
  logged-in user's session with their environment block. This is the
  real fix for "calc.exe should pop up on my desktop" - no Task
  Scheduler bridge required. Stdio is captured via inheritable
  anonymous pipes so the hook still returns stdout/stderr to the
  caller normally.

Implementation:
- New RunAsMode enum + RunAsConfig model on EndpointConfig
- ConfigStore round-trips RunAs.Password through DPAPI alongside
  bearer/HMAC/PFX secrets
- AdminPipeServer's secret-merge logic preserves the encrypted blob
  when the GUI saves an endpoint without re-typing the password
- New WebhookServer.Core.Execution.Native namespace with NativeMethods
  (P/Invoke) and InteractiveProcessLauncher (token-based launcher)
- ProcessExecutor branches on RunAs.Mode; the Service/SpecificUser
  paths share .NET's Process; InteractiveUser uses the launcher
- GUI editor gets a "Run as" section: dropdown + conditional
  username/password/load-profile fields under SpecificUser

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 09:08:08 -04:00
justin 27e5264714 Replace em-dashes in PowerShell scripts with ASCII hyphens
PowerShell 5.1 reads .ps1 files as the local ANSI codepage by default,
so non-ASCII characters get garbled. An em-dash inside a string literal
broke install-service.ps1 with a parser error. Sticking to ASCII in
script source avoids the entire class of issue.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 08:53:04 -04:00
justin ab53c96928 Fix install-service.ps1 service-existence check + propagate sc.exe failures
sc.exe query writes "The specified service does not exist" to stdout
when the service is missing, so checking truthy on its output was
useless — it always took the update branch and silently failed when
piped to Out-Null. Switch to Get-Service which returns $null cleanly,
and stop swallowing sc.exe output so missing-service / permission /
account errors actually surface as PowerShell errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 08:51:56 -04:00
justin 87bcb6807f GUI UX, secret visibility, browser-friendly hooks, deploy script
GUI:
- URL column in endpoint grid + Copy URL toolbar button so the full
  http://host:port/hook/<slug> is one click away
- Double-click a row to open the edit dialog
- Bearer/HMAC sections in the editor hide when the auth mode doesn't
  use them, and reappear with previously-entered values when switched
  back
- Log panel auto-scroll checkbox (default on) plus 3s polling so log
  entries stream in without manual refresh
- Secret fields are now plain text with a Copy button. Anyone who can
  open the admin-pipe-ACL'd GUI is already SYSTEM-equivalent on the
  host, so masking the value just made recovery harder. PFX password
  in Server Settings gets the same treatment.

Service:
- Admin pipe ops log info-level lines on every mutation
  (create/update/delete/enable/disable/update-config/bind-https) so
  GUI activity is visible in the Serilog file
- /hook/{slug} accepts GET as well as POST so a browser smoke-test
  works without curl
- /favicon.ico returns 204 so browser hits don't pollute logs with 404s
- AdminPipeServer no longer strips plaintext secrets when sending
  config to the GUI; the pipe ACL already restricts to SYSTEM/Admins

Scripts:
- New deploy.ps1: stops + republishes + copies binaries to
  C:\Program Files\WebhookServer + (re)installs the Windows Service
- install-service.ps1 now uses sc.exe argv splatting consistently for
  both create and config paths

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 08:47:11 -04:00
justin 882d5332b4 Add dev-launch.ps1 to start service + GUI for visual smoke testing
Stands up an isolated data root, seeds a single sample endpoint, opens
the service in its own window, then launches the GUI with the matching
WEBHOOKSERVER_DATA environment variable. Refuses to run from a
non-elevated shell since the admin pipe is ACL'd to SYSTEM and
Administrators only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 08:17:05 -04:00
justin ebac2c7c04 Fix PowerShell -Command argv binding so positional args + stdin work
PowerShell's -Command treats trailing argv entries as part of the
command-line text rather than as $args, so a hook with an inline
command and an arg template raised a parser error. Wrap inline commands
in a scriptblock with @args splat, and pipe $input into the block so
{{body.*}} arg templates AND stdin JSON both reach the script. Verified
end-to-end against ping, bearer (good/bad/missing), HMAC (good/bad/missing),
IP allowlist deny, async 202, and stdin+template combined.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 08:12:48 -04:00
justin 8ecfe84540 Initial WebhookServer implementation
Add the .NET 8 solution scaffolded against PLAN.md. Three projects share
WebhookServer.Core (models, auth, execution, storage, IPC, callbacks)
and WebhookServer.Service hosts an embedded Kestrel listener plus the
named-pipe admin server. WebhookServer.Gui is a thin MVVM client over
the pipe. Includes 25 unit tests covering HMAC verification, bearer
auth, IP allowlist parsing, arg-template rendering, DPAPI round-trip,
and the encrypt-on-save config store.

Install/uninstall PowerShell scripts default to LocalSystem and accept
a domain user or gMSA via -ServiceAccount.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 22:04:52 -04:00
justin 2f61b342af Document service account choices for AD-aware hooks
Add a Service account section to PLAN.md and README.md covering
LocalSystem, domain user, and gMSA install paths so users running AD
PowerShell scripts know which identity to pick. Drop the stale
"outbound webhook delivery" out-of-scope bullet now that callbacks are
in v1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 21:32:07 -04:00
105 changed files with 8219 additions and 64 deletions
+102
View File
@@ -0,0 +1,102 @@
name: Release (Gitea)
# Lives in .gitea/workflows/ so it runs on Gitea Actions only. The GitHub-side
# release lives in .github/workflows/release.yml.
#
# Triggered automatically on v* tag pushes; can also be invoked manually via
# workflow_dispatch with a version override (useful for testing the runner
# without bumping the project version).
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
version:
description: 'Version to build (e.g. 0.1.4). Defaults to Directory.Build.props.'
required: false
jobs:
build-installer:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Resolve version
id: ver
shell: pwsh
run: |
if ('${{ github.event_name }}' -eq 'push') {
$v = '${{ github.ref_name }}'.TrimStart('v')
} elseif ('${{ inputs.version }}') {
$v = '${{ inputs.version }}'
} else {
[xml]$p = Get-Content Directory.Build.props
$v = $p.Project.PropertyGroup.Version
}
"version=$v" | Out-File $env:GITHUB_OUTPUT -Append
Write-Host "Building version $v"
- name: Restore + test
shell: pwsh
run: |
dotnet restore WebhookServer.sln
dotnet test WebhookServer.sln -c Release
- name: Ensure Inno Setup is installed
shell: pwsh
run: |
if (-not (Get-Command iscc -ErrorAction SilentlyContinue) -and `
-not (Test-Path 'C:\Program Files (x86)\Inno Setup 6\ISCC.exe') -and `
-not (Test-Path 'C:\Program Files\Inno Setup 6\ISCC.exe')) {
choco install innosetup --no-progress -y
}
- name: Build installer
shell: pwsh
run: ./scripts/build-installer.ps1 -VersionOverride ${{ steps.ver.outputs.version }}
- name: Upload installer artifact
uses: actions/upload-artifact@v4
with:
name: WebhookServer-Setup-${{ steps.ver.outputs.version }}
path: dist/WebhookServer-Setup-*.exe
- name: Create Gitea release with installer attached
if: startsWith(github.ref, 'refs/tags/v')
shell: pwsh
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
$version = '${{ steps.ver.outputs.version }}'
$tag = '${{ github.ref_name }}'
$repo = '${{ github.repository }}'
$serverUrl = '${{ github.server_url }}'
$apiBase = "$serverUrl/api/v1/repos/$repo"
$headers = @{ Authorization = "token $env:GITEA_TOKEN" }
# 1. Create the release.
$isPre = $version.StartsWith('0.')
$createBody = @{
tag_name = $tag
name = "Webhook Server $version"
body = "Automated build via Gitea Actions runner."
draft = $false
prerelease = $isPre
} | ConvertTo-Json
$rel = Invoke-RestMethod -Uri "$apiBase/releases" -Method Post `
-Headers $headers -ContentType 'application/json' -Body $createBody
Write-Host "Created release id=$($rel.id) tag=$tag"
# 2. Attach the installer.
$file = Get-Item "dist/WebhookServer-Setup-$version.exe"
$uploadUri = "$apiBase/releases/$($rel.id)/assets?name=$($file.Name)"
Invoke-RestMethod -Uri $uploadUri -Method Post -Headers $headers `
-ContentType 'application/octet-stream' -InFile $file.FullName | Out-Null
Write-Host "Uploaded $($file.Name) ($([math]::Round($file.Length / 1MB, 2)) MB) to $tag"
+28
View File
@@ -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
+74
View File
@@ -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
+31
View File
@@ -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
+14
View File
@@ -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>
+29
View File
@@ -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.
+67 -64
View File
@@ -1,88 +1,91 @@
# 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 |
| - Serilog file logging |
+------------------------------+
^
C:\ProgramData\WebhookServer\
- config.json (DPAPI-encrypted secrets)
- logs\*.log
+------------------+ 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)
- 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**
- [GitHub-style HMAC-signed webhook](docs/recipes/github-style-hmac.md)
- [Pop UI on the user's desktop](docs/recipes/ui-on-desktop.md)
A ready-to-drop-in Zerto-side script is included at [`scripts/examples/zerto-post-failover.ps1`](scripts/examples/zerto-post-failover.ps1).
## 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.
+50
View File
@@ -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
+32
View File
@@ -0,0 +1,32 @@
# 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
- [GitHub-style HMAC-signed webhook](recipes/github-style-hmac.md)
- [Pop UI on the user's desktop](recipes/ui-on-desktop.md)
The flagship Zerto recipe also ships with a **ready-to-use Zerto-side post-script** at [`scripts/examples/zerto-post-failover.ps1`](../scripts/examples/zerto-post-failover.ps1).
## 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)
+77
View File
@@ -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).
+108
View File
@@ -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.
+131
View File
@@ -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 (13 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
+122
View File
@@ -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.
+68
View File
@@ -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.
+243
View File
@@ -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.
+78
View File
@@ -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
```
+149
View File
@@ -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).
+136
View File
@@ -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
+90
View File
@@ -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
```
+76
View File
@@ -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: 210 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.
+124
View File
@@ -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

+111
View File
@@ -0,0 +1,111 @@
<#
.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
Push-Location $issDir
try {
Write-Host " cwd=$issDir"
& $iscc "/DAppVersion=$version" $issName
$exit = $LASTEXITCODE
} finally {
Pop-Location
}
if ($exit -ne 0) { throw "Inno Setup compile failed (exit $exit)" }
$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
+102
View File
@@ -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"
+83
View File
@@ -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')"
+78
View File
@@ -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
}
+138
View File
@@ -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)"
+90
View File
@@ -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
+159
View File
@@ -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
}
+38
View File
@@ -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,
};
}
+114
View File
@@ -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) },
};
}
+29
View File
@@ -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; }
}
+70
View File
@@ -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>
+13
View File
@@ -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>
+5
View File
@@ -0,0 +1,5 @@
namespace WebhookServer.Gui;
public partial class App : Application
{
}
+10
View File
@@ -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();
}
+15
View File
@@ -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;
+156
View File
@@ -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>
+87
View File
@@ -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>
+24
View File
@@ -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");
}
}
}
}
+141
View File
@@ -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));
}
+16
View File
@@ -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");
}
+117
View File
@@ -0,0 +1,117 @@
using System.Runtime.Versioning;
using WebhookServer.Core.Auth;
using WebhookServer.Core.Models;
using WebhookServer.Core.Storage;
namespace WebhookServer.Service;
/// <summary>
/// In-memory authoritative copy of the current <see cref="ServerConfig"/>. Holds parsed
/// helpers (allowlists keyed by endpoint id, slug → endpoint map) and notifies subscribers
/// when the config is replaced so the listener and dispatcher can react.
/// </summary>
[SupportedOSPlatform("windows")]
public sealed class ServiceState
{
private readonly ConfigStore _store;
private readonly object _lock = new();
private ServerConfig _config = new();
private Dictionary<string, EndpointConfig> _bySlug = new(StringComparer.Ordinal);
private Dictionary<Guid, IpAllowList> _allowLists = new();
private IpAllowList _trustedProxies = IpAllowList.Parse(Array.Empty<string>());
public DateTimeOffset StartedAt { get; } = DateTimeOffset.UtcNow;
public event EventHandler? ListenerSettingsChanged;
public ServiceState(ConfigStore store)
{
_store = store;
}
public ServerConfig Snapshot()
{
lock (_lock) return _config;
}
public bool TryGetEndpoint(string slug, out EndpointConfig endpoint)
{
lock (_lock)
{
return _bySlug.TryGetValue(slug, out endpoint!);
}
}
public IpAllowList GetAllowList(Guid endpointId)
{
lock (_lock)
{
return _allowLists.TryGetValue(endpointId, out var l) ? l : IpAllowList.Parse(Array.Empty<string>());
}
}
public IpAllowList GetTrustedProxies()
{
lock (_lock) return _trustedProxies;
}
public async Task LoadAsync(CancellationToken ct = default)
{
var loaded = await _store.LoadAsync(ct).ConfigureAwait(false);
ConfigStore.DecryptSecrets(loaded);
Replace(loaded, listenerChanged: true);
}
public async Task ReplaceAsync(ServerConfig replacement, CancellationToken ct = default)
{
// Save to disk first; that re-encrypts secrets in place. Then publish in-memory.
var listenerChanged = HasListenerSettingsChanged(_config, replacement);
await _store.SaveAsync(replacement, ct).ConfigureAwait(false);
// SaveAsync filled in Encrypted; ensure Plaintext is populated for runtime use.
ConfigStore.DecryptSecrets(replacement);
Replace(replacement, listenerChanged);
}
private void Replace(ServerConfig cfg, bool listenerChanged)
{
var bySlug = new Dictionary<string, EndpointConfig>(StringComparer.Ordinal);
var allow = new Dictionary<Guid, IpAllowList>();
foreach (var ep in cfg.Endpoints)
{
if (!string.IsNullOrEmpty(ep.Slug))
bySlug[ep.Slug] = ep;
allow[ep.Id] = IpAllowList.Parse(ep.AllowedClients);
}
var trusted = IpAllowList.Parse(cfg.TrustedProxies);
lock (_lock)
{
_config = cfg;
_bySlug = bySlug;
_allowLists = allow;
_trustedProxies = trusted;
}
if (listenerChanged)
ListenerSettingsChanged?.Invoke(this, EventArgs.Empty);
}
private static bool HasListenerSettingsChanged(ServerConfig oldCfg, ServerConfig newCfg)
{
if (oldCfg.HttpPort != newCfg.HttpPort) return true;
if (!oldCfg.BindAddresses.SequenceEqual(newCfg.BindAddresses, StringComparer.OrdinalIgnoreCase)) return true;
var a = oldCfg.HttpsBinding;
var b = newCfg.HttpsBinding;
if ((a is null) != (b is null)) return true;
if (a is not null && b is not null)
{
if (a.Kind != b.Kind || a.Port != b.Port || a.PfxPath != b.PfxPath || a.Thumbprint != b.Thumbprint)
return true;
}
// DisplayHost is cosmetic; don't restart for it.
return false;
}
}
+328
View File
@@ -0,0 +1,328 @@
using System.Net;
using System.Net.Sockets;
using System.Runtime.Versioning;
using System.Text;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using WebhookServer.Core.Auth;
using WebhookServer.Core.Callbacks;
using WebhookServer.Core.Execution;
using WebhookServer.Core.Models;
using ExecCtx = WebhookServer.Core.Execution.ExecutionContext;
namespace WebhookServer.Service;
[SupportedOSPlatform("windows")]
public sealed class WebhookRouter
{
private readonly ServiceState _state;
private readonly IExecutor _executor;
private readonly ConcurrencyGate _gate;
private readonly CallbackDispatcher _callbacks;
private readonly ILogger<WebhookRouter> _logger;
public WebhookRouter(
ServiceState state,
IExecutor executor,
ConcurrencyGate gate,
CallbackDispatcher callbacks,
ILogger<WebhookRouter> logger)
{
_state = state;
_executor = executor;
_gate = gate;
_callbacks = callbacks;
_logger = logger;
}
public async Task HandleAsync(HttpContext http, string slug)
{
var runId = Guid.NewGuid().ToString("N");
if (!_state.TryGetEndpoint(slug, out var endpoint) || !endpoint.Enabled)
{
http.Response.StatusCode = StatusCodes.Status404NotFound;
return;
}
var clientIp = ResolveClientIp(http);
// 1. IP allowlist (before auth, before reading body).
var allowList = _state.GetAllowList(endpoint.Id);
if (!allowList.IsEmpty && (clientIp is null || !allowList.Contains(clientIp)))
{
_logger.LogWarning("IP {Ip} blocked for endpoint {Slug} (run {RunId})", clientIp, slug, runId);
http.Response.StatusCode = StatusCodes.Status403Forbidden;
return;
}
// 2. Capture raw body bytes (needed for HMAC verification and stdin/template).
byte[] bodyBytes;
try
{
using var ms = new MemoryStream();
await http.Request.Body.CopyToAsync(ms, http.RequestAborted).ConfigureAwait(false);
bodyBytes = ms.ToArray();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed reading body for {Slug} (run {RunId})", slug, runId);
http.Response.StatusCode = StatusCodes.Status400BadRequest;
return;
}
// 3. Auth.
var authResult = VerifyAuth(endpoint, http, bodyBytes);
if (!authResult.Success)
{
_logger.LogWarning("Auth failed for {Slug}: {Reason} (run {RunId})", slug, authResult.Reason, runId);
http.Response.StatusCode = StatusCodes.Status401Unauthorized;
return;
}
// 4. Build execution context.
var bodyString = Encoding.UTF8.GetString(bodyBytes);
JsonNode? bodyJson = null;
try
{
if (bodyBytes.Length > 0)
bodyJson = JsonNode.Parse(bodyBytes);
}
catch
{
// Non-JSON body — leave bodyJson null so {{body.*}} renders empty.
}
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var (key, value) in http.Request.Headers)
headers[key] = value.ToString();
var query = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var (key, value) in http.Request.Query)
query[key] = value.ToString();
var route = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) { { "slug", slug } };
var ctx = new ExecCtx
{
RunId = runId,
Slug = slug,
BodyBytes = bodyBytes,
BodyString = bodyString,
BodyJson = bodyJson,
Headers = headers,
Query = query,
Route = route,
};
// 5. Dispatch.
if (endpoint.ResponseMode == ResponseMode.Async)
{
_ = Task.Run(() => RunAndDispatchCallbackAsync(endpoint, ctx, http.RequestAborted));
http.Response.StatusCode = StatusCodes.Status202Accepted;
await WriteJsonAsync(http, new { runId, accepted = true }).ConfigureAwait(false);
return;
}
var result = await RunAsync(endpoint, ctx, http.RequestAborted).ConfigureAwait(false);
LogResult(endpoint, ctx, result);
DispatchCallback(endpoint, ctx, result);
if (result.LaunchError is not null)
{
http.Response.StatusCode = StatusCodes.Status500InternalServerError;
await WriteJsonAsync(http, new { runId, error = result.LaunchError }).ConfigureAwait(false);
return;
}
http.Response.StatusCode = endpoint.FailOnNonZeroExit && !result.Succeeded
? StatusCodes.Status502BadGateway
: StatusCodes.Status200OK;
await WriteJsonAsync(http, new
{
runId,
exitCode = result.ExitCode,
timedOut = result.TimedOut,
durationMs = (long)result.Duration.TotalMilliseconds,
stdout = result.Stdout,
stderr = result.Stderr,
stdoutTruncated = result.StdoutTruncated,
stderrTruncated = result.StderrTruncated,
}).ConfigureAwait(false);
}
private async Task RunAndDispatchCallbackAsync(EndpointConfig endpoint, ExecCtx ctx, CancellationToken ct)
{
try
{
var result = await RunAsync(endpoint, ctx, ct).ConfigureAwait(false);
LogResult(endpoint, ctx, result);
DispatchCallback(endpoint, ctx, result);
}
catch (Exception ex)
{
_logger.LogError(ex, "Async run failed for {Slug} (run {RunId})", ctx.Slug, ctx.RunId);
}
}
private void LogResult(EndpointConfig endpoint, ExecCtx ctx, ExecutionResult result)
{
if (result.LaunchError is not null)
{
_logger.LogWarning("Run {RunId} {Slug} failed to launch: {Error}",
ctx.RunId, ctx.Slug, result.LaunchError);
return;
}
if (result.TimedOut)
{
_logger.LogWarning("Run {RunId} {Slug} timed out after {Sec}s; process killed",
ctx.RunId, ctx.Slug, endpoint.TimeoutSeconds);
return;
}
var stdout = TruncateForLog(result.Stdout, 512);
var stderr = TruncateForLog(result.Stderr, 512);
if (result.Succeeded)
{
_logger.LogInformation(
"Run {RunId} {Slug} ok exit={Exit} dur={Ms}ms stdout={Stdout}{StderrPart}",
ctx.RunId, ctx.Slug, result.ExitCode, (long)result.Duration.TotalMilliseconds,
stdout, string.IsNullOrEmpty(stderr) ? "" : $" stderr={stderr}");
}
else
{
_logger.LogWarning(
"Run {RunId} {Slug} non-zero exit={Exit} dur={Ms}ms stdout={Stdout} stderr={Stderr}",
ctx.RunId, ctx.Slug, result.ExitCode, (long)result.Duration.TotalMilliseconds,
stdout, stderr);
}
}
private static string TruncateForLog(string s, int max)
{
if (string.IsNullOrEmpty(s)) return "(empty)";
var trimmed = s.Trim();
if (trimmed.Length <= max) return trimmed;
return trimmed.Substring(0, max) + $"... [+{trimmed.Length - max} chars]";
}
private async Task<ExecutionResult> RunAsync(EndpointConfig endpoint, ExecCtx ctx, CancellationToken ct)
{
if (endpoint.Serialize)
{
using var _ = await _gate.AcquireAsync(endpoint.Id, ct).ConfigureAwait(false);
return await _executor.RunAsync(endpoint, ctx, ct).ConfigureAwait(false);
}
return await _executor.RunAsync(endpoint, ctx, ct).ConfigureAwait(false);
}
private void DispatchCallback(EndpointConfig endpoint, ExecCtx ctx, ExecutionResult result)
{
var cb = endpoint.Callback;
if (cb is null || string.IsNullOrEmpty(cb.Url)) return;
var trigger = cb.Trigger;
var fire = trigger switch
{
CallbackTrigger.OnSuccess => result.Succeeded,
CallbackTrigger.OnFailure => !result.Succeeded,
_ => true,
};
if (!fire) return;
var stdout = TruncateBytes(result.Stdout, cb.MaxOutputBytes, out var stdoutCut);
var stderr = TruncateBytes(result.Stderr, cb.MaxOutputBytes, out var stderrCut);
var payload = new CallbackPayload
{
RunId = ctx.RunId,
Endpoint = ctx.Slug,
StartedAt = result.StartedAt,
CompletedAt = result.CompletedAt,
DurationMs = (long)result.Duration.TotalMilliseconds,
ExitCode = result.ExitCode,
Succeeded = result.Succeeded,
TimedOut = result.TimedOut,
Stdout = stdout,
Stderr = stderr,
StdoutTruncated = result.StdoutTruncated || stdoutCut,
StderrTruncated = result.StderrTruncated || stderrCut,
};
_callbacks.Enqueue(new CallbackEnvelope
{
EndpointId = endpoint.Id,
EndpointSlug = ctx.Slug,
Config = cb,
Payload = payload,
});
}
private static string TruncateBytes(string s, int maxBytes, out bool truncated)
{
truncated = false;
if (string.IsNullOrEmpty(s)) return s;
if (maxBytes <= 0) { truncated = true; return ""; }
var bytes = Encoding.UTF8.GetByteCount(s);
if (bytes <= maxBytes) return s;
// Trim from the end until under cap. Cheap and good enough.
var bs = Encoding.UTF8.GetBytes(s);
truncated = true;
return Encoding.UTF8.GetString(bs.AsSpan(0, maxBytes));
}
private static async Task WriteJsonAsync(HttpContext http, object payload)
{
http.Response.ContentType = "application/json; charset=utf-8";
await System.Text.Json.JsonSerializer.SerializeAsync(http.Response.Body, payload, options: new() { PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase }, http.RequestAborted).ConfigureAwait(false);
}
private AuthResult VerifyAuth(EndpointConfig endpoint, HttpContext http, byte[] body)
{
switch (endpoint.AuthMode)
{
case AuthMode.None:
return AuthResult.Ok();
case AuthMode.Bearer:
var token = endpoint.Bearer?.Secret.Plaintext ?? "";
return BearerVerifier.Verify(http.Request.Headers.Authorization.ToString(), token);
case AuthMode.Hmac:
if (endpoint.Hmac is null) return AuthResult.Fail("HMAC config missing");
var headerName = endpoint.Hmac.HeaderName;
var presented = http.Request.Headers.TryGetValue(headerName, out var v) ? v.ToString() : null;
return HmacVerifier.Verify(body, presented, endpoint.Hmac);
default:
return AuthResult.Fail("unknown auth mode");
}
}
private IPAddress? ResolveClientIp(HttpContext http)
{
var direct = http.Connection.RemoteIpAddress;
if (direct is null) return null;
var trustedProxies = _state.GetTrustedProxies();
if (trustedProxies.IsEmpty || !trustedProxies.Contains(direct))
return Normalize(direct);
// Direct hop is a trusted proxy — honor X-Forwarded-For (leftmost).
if (http.Request.Headers.TryGetValue("X-Forwarded-For", out var xff) && !string.IsNullOrEmpty(xff))
{
var first = xff.ToString().Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).FirstOrDefault();
if (!string.IsNullOrEmpty(first) && IPAddress.TryParse(first, out var parsed))
return Normalize(parsed);
}
return Normalize(direct);
}
private static IPAddress Normalize(IPAddress address)
{
if (address.AddressFamily == AddressFamily.InterNetworkV6 && address.IsIPv4MappedToIPv6)
return address.MapToIPv4();
return address;
}
}
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>dotnet-WebhookServer.Service-57f4579b-6131-4fab-a6ad-2865b038cc2e</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="8.0.1" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.3" />
<PackageReference Include="Serilog.Sinks.Async" Version="2.1.0" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\WebhookServer.Core\WebhookServer.Core.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.Hosting.Lifetime": "Information"
}
}
}
@@ -0,0 +1,74 @@
using System.Text;
using System.Text.Json.Nodes;
using WebhookServer.Core.Execution;
using ExecCtx = WebhookServer.Core.Execution.ExecutionContext;
using Xunit;
namespace WebhookServer.Core.Tests;
public class ArgTemplateRendererTests
{
private static ExecCtx Ctx(string body, Dictionary<string, string>? headers = null, Dictionary<string, string>? query = null)
{
var bytes = Encoding.UTF8.GetBytes(body);
return new ExecCtx
{
RunId = "r",
Slug = "s",
BodyBytes = bytes,
BodyString = body,
BodyJson = body.Length == 0 ? null : JsonNode.Parse(body),
Headers = headers ?? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase),
Query = query ?? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase),
Route = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) { { "slug", "deploy" } },
};
}
[Fact]
public void Whitespace_separated_tokens_become_separate_args()
{
var ctx = Ctx("{\"name\":\"alice\",\"id\":7}");
var args = ArgTemplateRenderer.Render("{{body.name}} {{body.id}}", ctx);
Assert.Equal(new[] { "alice", "7" }, args);
}
[Fact]
public void Header_lookup_is_case_insensitive()
{
var ctx = Ctx("", headers: new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) { ["X-GitHub-Event"] = "push" });
var args = ArgTemplateRenderer.Render("{{header.x-github-event}}", ctx);
Assert.Equal(new[] { "push" }, args);
}
[Fact]
public void Missing_path_renders_empty_string()
{
var ctx = Ctx("{}");
var args = ArgTemplateRenderer.Render("{{body.nope}}", ctx);
Assert.Equal(new[] { "" }, args);
}
[Fact]
public void Route_value_resolves()
{
var ctx = Ctx("");
var args = ArgTemplateRenderer.Render("{{route.slug}}", ctx);
Assert.Equal(new[] { "deploy" }, args);
}
[Fact]
public void Multiple_substitutions_in_one_token_are_concatenated()
{
var ctx = Ctx("{\"a\":\"x\",\"b\":\"y\"}");
var args = ArgTemplateRenderer.Render("{{body.a}}-{{body.b}}", ctx);
Assert.Equal(new[] { "x-y" }, args);
}
[Fact]
public void Nested_json_path_resolves()
{
var ctx = Ctx("{\"repo\":{\"name\":\"acme\"}}");
var args = ArgTemplateRenderer.Render("{{body.repo.name}}", ctx);
Assert.Equal(new[] { "acme" }, args);
}
}
@@ -0,0 +1,27 @@
using WebhookServer.Core.Auth;
using Xunit;
namespace WebhookServer.Core.Tests;
public class BearerVerifierTests
{
[Fact]
public void Accepts_correct_token() =>
Assert.True(BearerVerifier.Verify("Bearer s3cret", "s3cret").Success);
[Fact]
public void Rejects_wrong_token() =>
Assert.False(BearerVerifier.Verify("Bearer nope", "s3cret").Success);
[Fact]
public void Rejects_missing_header() =>
Assert.False(BearerVerifier.Verify(null, "s3cret").Success);
[Fact]
public void Rejects_non_bearer_scheme() =>
Assert.False(BearerVerifier.Verify("Basic s3cret", "s3cret").Success);
[Fact]
public void Rejects_when_server_secret_empty() =>
Assert.False(BearerVerifier.Verify("Bearer s3cret", "").Success);
}

Some files were not shown because too many files have changed in this diff Show More