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
This commit was merged in pull request #1.
This commit is contained in:
2026-05-08 10:05:11 -04:00
81 changed files with 5911 additions and 2 deletions
+27
View File
@@ -0,0 +1,27 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
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
+71
View File
@@ -0,0 +1,71 @@
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:
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
+14
View File
@@ -0,0 +1,14 @@
<Project>
<PropertyGroup>
<Version>0.1.0</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. 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) ## 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. 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.
+25 -2
View File
@@ -69,7 +69,31 @@ sc.exe create WebhookServer binPath= "C:\Program Files\WebhookServer\WebhookServ
sc.exe start WebhookServer sc.exe start WebhookServer
``` ```
`scripts/install-service.ps1` will wrap this once implemented. `scripts/install-service.ps1` will wrap this once implemented and will accept a `-ServiceAccount` parameter.
## Service account & Active Directory
The service runs as `LocalSystem` by default — fine for local-only scripts and read-only AD queries (it authenticates to the domain as the computer account). If your webhook scripts need to **modify** AD (password resets, group changes, etc.), run the service under an account with the right delegated rights:
- **Recommended: gMSA** — Active Directory generates and rotates the password automatically.
```powershell
# on a DC, once
New-ADServiceAccount -Name svc-webhookserver -DNSHostName host.domain.local `
-PrincipalsAllowedToRetrieveManagedPassword "DOMAIN\WebhookHosts"
# on the webhook host
Install-ADServiceAccount svc-webhookserver
sc.exe create WebhookServer binPath= "..." obj= "DOMAIN\svc-webhookserver$" start= auto
```
Note the trailing `$` and the absence of `password=`.
- **Plain domain user** — works on older domains, but you own password rotation:
```powershell
sc.exe create WebhookServer binPath= "..." obj= "DOMAIN\svc-webhookserver" password= "..." start= auto
```
Don't use `LocalService` — it has no network identity and cannot talk to a domain controller.
> Heads up: any account the service runs under is the account your hook scripts run under. `LocalSystem` is the most powerful local account on the machine — treat webhook script contents as privileged.
## Configuration ## Configuration
@@ -78,7 +102,6 @@ The service reads `C:\ProgramData\WebhookServer\config.json`. Edit it through th
## Out of scope for v1 ## Out of scope for v1
- Importing/exporting config across machines (DPAPI LocalMachine scope ties decryption to the host). - Importing/exporting config across machines (DPAPI LocalMachine scope ties decryption to the host).
- Outbound webhook delivery / retry queues.
- Per-endpoint rate limiting. - Per-endpoint rate limiting.
- Multi-user RBAC for the GUI. - Multi-user RBAC for the GUI.
- Auto-update. - Auto-update.
+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
+79
View File
@@ -0,0 +1,79 @@
; 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}README.md"; DestDir: "{app}"; Flags: ignoreversion
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
Filename: "{app}\{#AppExeName}"; \
Description: "Launch {#AppName}"; \
Flags: postinstall nowait skipifsilent
[UninstallRun]
Filename: "powershell.exe"; \
Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{app}\scripts\uninstall-service.ps1"""; \
Flags: runhidden; \
RunOnceId: "RemoveWebhookService"
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

+68
View File
@@ -0,0 +1,68 @@
<#
.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. 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"
& $iscc "/DAppVersion=$version" $iss
if ($LASTEXITCODE -ne 0) { throw 'Inno Setup compile failed' }
$out = Get-Item (Join-Path $dist "WebhookServer-Setup-$version.exe")
Write-Host ""
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')"
+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
+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,
};
}
+107
View File
@@ -0,0 +1,107 @@
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 sealed class BackupEntry
{
public string FileName { get; set; } = "";
public DateTimeOffset SavedAt { get; set; }
public long SizeBytes { get; set; }
}
public sealed class RestoreBackupArgs
{
public string FileName { 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,151 @@
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");
File.Copy(Path, backupPath, overwrite: false);
PruneBackups(backupsDir, retain: 30);
}
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")
.OrderByDescending(f => f.Name)
.Skip(retain);
foreach (var f in stale)
{
try { f.Delete(); } 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;
+157
View File
@@ -0,0 +1,157 @@
<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="_Backups"
ItemsSource="{Binding Backups}"
SubmenuOpened="OnBackupsSubmenuOpened">
<MenuItem.ItemContainerStyle>
<Style TargetType="MenuItem">
<Setter Property="Header">
<Setter.Value>
<MultiBinding StringFormat="{}{0:yyyy-MM-dd HH:mm:ss} ({1:n0} bytes)">
<Binding Path="SavedAt"/>
<Binding Path="SizeBytes"/>
</MultiBinding>
</Setter.Value>
</Setter>
<Setter Property="Command" Value="{Binding DataContext.RestoreBackupCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
<Setter Property="CommandParameter" Value="{Binding}"/>
</Style>
</MenuItem.ItemContainerStyle>
</MenuItem>
<Separator/>
<MenuItem Header="E_xit" Command="{Binding ExitCommand}"/>
</MenuItem>
<MenuItem Header="_Server">
<MenuItem Header="_Settings…" Command="{Binding EditServerSettingsCommand}"/>
<Separator/>
<MenuItem Header="_Restart service" Command="{Binding RestartServiceCommand}"/>
</MenuItem>
<MenuItem Header="_Help">
<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"/>
<Setter Property="ContextMenu">
<Setter.Value>
<ContextMenu>
<MenuItem Header="_Edit…" Command="{Binding DataContext.EditEndpointCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
<MenuItem Header="_Copy URL" Command="{Binding DataContext.CopyEndpointUrlCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
<Separator/>
<MenuItem Header="Toggle _enabled"
Command="{Binding DataContext.ToggleEnabledCommand, RelativeSource={RelativeSource AncestorType=Window}}"
CommandParameter="{Binding}"/>
<Separator/>
<MenuItem Header="_Delete…" Command="{Binding DataContext.DeleteEndpointCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
</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>
+61
View File
@@ -0,0 +1,61 @@
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;
public MainWindow()
{
InitializeComponent();
_vm = new MainViewModel(new AdminPipeClient());
DataContext = _vm;
_tray = new TrayIcon(
resolveMainWindow: () => Application.Current.MainWindow,
restartServiceAsync: async () => await new AdminPipeClient().RestartListenerAsync());
Loaded += async (_, _) => await _vm.RefreshCommand.ExecuteAsync(null);
StateChanged += OnStateChanged;
Closed += (_, _) => _tray.Dispose();
}
private void OnStateChanged(object? sender, EventArgs e)
{
// Minimize-to-tray: hide the window when the user minimizes; restoring is
// via the tray icon's double-click or context menu.
if (WindowState == WindowState.Minimized)
{
Hide();
ShowInTaskbar = false;
}
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);
}
private async void OnBackupsSubmenuOpened(object sender, RoutedEventArgs e)
{
if (DataContext is MainViewModel vm)
await vm.RefreshBackupsCommand.ExecuteAsync(null);
}
}
@@ -0,0 +1,103 @@
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);
}
@@ -0,0 +1,86 @@
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;
public TrayIcon(Func<Window?> resolveMainWindow, Func<Task> restartServiceAsync)
{
_resolveMainWindow = resolveMainWindow;
_restartServiceAsync = restartServiceAsync;
_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, (_, _) => Application.Current.Shutdown());
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,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,343 @@
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;
private readonly DispatcherTimer _logTimer;
public MainViewModel(AdminPipeClient client)
{
_client = client;
_logTimer = new DispatcherTimer(DispatcherPriority.Background) { Interval = TimeSpan.FromSeconds(3) };
_logTimer.Tick += async (_, _) => await RefreshLogTailAsync();
_logTimer.Start();
}
[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);
}
}
[ObservableProperty] private System.Collections.ObjectModel.ObservableCollection<BackupEntry> _backups = new();
[RelayCommand]
private async Task RefreshBackupsAsync()
{
try
{
var list = await _client.ListBackupsAsync().ConfigureAwait(false);
Application.Current.Dispatcher.Invoke(() =>
{
Backups.Clear();
foreach (var b in list) Backups.Add(b);
});
}
catch { /* ignore - backup listing isn't critical */ }
}
[RelayCommand]
private async Task RestoreBackupAsync(BackupEntry? entry)
{
if (entry is null) return;
var ok = MessageBox.Show(
$"Restore configuration from {entry.FileName} ({entry.SavedAt:yyyy-MM-dd HH:mm})?\n\nA backup of the current config will be saved first.",
"Restore backup",
MessageBoxButton.OKCancel,
MessageBoxImage.Question);
if (ok != MessageBoxResult.OK) return;
try
{
await _client.RestoreBackupAsync(entry.FileName).ConfigureAwait(false);
await RefreshAsync().ConfigureAwait(false);
}
catch (Exception ex) { ShowError("Restore failed", ex); }
}
[RelayCommand]
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 backup of the current config will be saved first.",
"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 Exit()
{
Application.Current.Shutdown();
}
[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,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,27 @@
<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>
<AssemblyTitle>Webhook Server</AssemblyTitle>
</PropertyGroup>
<ItemGroup>
<Resource Include="..\..\resources\webhook-server.ico" Link="webhook-server.ico" />
<Resource Include="..\..\resources\webhook-server.png" Link="webhook-server.png" />
</ItemGroup>
</Project>
@@ -0,0 +1,361 @@
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()));
}
default:
return AdminResponse.Failure($"unknown op '{request.Op}'");
}
}
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")
.OrderByDescending(f => f.Name)
.Take(50)
.Select(f => new BackupEntry
{
FileName = f.Name,
SavedAt = f.LastWriteTimeUtc,
SizeBytes = f.Length,
})
.ToList();
}
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);
}
+140
View File
@@ -0,0 +1,140 @@
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>();
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);
}
@@ -0,0 +1,52 @@
using System.Runtime.InteropServices;
using WebhookServer.Core.Models;
using WebhookServer.Core.Storage;
using Xunit;
namespace WebhookServer.Core.Tests;
public class ConfigStoreTests
{
[Fact]
public async Task Save_then_load_preserves_endpoints_and_encrypts_secrets()
{
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return;
var path = Path.Combine(Path.GetTempPath(), $"webhook-test-{Guid.NewGuid():N}.json");
try
{
var store = new ConfigStore(path);
var cfg = new ServerConfig
{
HttpPort = 9000,
Endpoints =
{
new EndpointConfig
{
Slug = "deploy",
AuthMode = AuthMode.Bearer,
Bearer = new BearerOptions { Secret = ProtectedString.FromPlaintext("topsecret") },
},
},
};
await store.SaveAsync(cfg);
// Persisted config must not contain plaintext.
var rawJson = await File.ReadAllTextAsync(path);
Assert.DoesNotContain("topsecret", rawJson);
Assert.Contains("encrypted", rawJson);
var reloaded = await store.LoadAsync();
ConfigStore.DecryptSecrets(reloaded);
var ep = Assert.Single(reloaded.Endpoints);
Assert.Equal("deploy", ep.Slug);
Assert.Equal("topsecret", ep.Bearer!.Secret.Plaintext);
}
finally
{
if (File.Exists(path)) File.Delete(path);
}
}
}
@@ -0,0 +1,27 @@
using System.Runtime.InteropServices;
using WebhookServer.Core.Storage;
using Xunit;
namespace WebhookServer.Core.Tests;
public class DpapiSecretTests
{
[Fact]
public void Round_trip_recovers_original_value()
{
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return;
var original = "topsecret-é-🚀";
var encrypted = DpapiSecret.Protect(original);
Assert.NotEmpty(encrypted);
var decrypted = DpapiSecret.Unprotect(encrypted);
Assert.Equal(original, decrypted);
}
[Fact]
public void Empty_string_round_trips_as_empty()
{
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return;
Assert.Equal("", DpapiSecret.Unprotect(DpapiSecret.Protect("")));
}
}
@@ -0,0 +1,79 @@
using System.Security.Cryptography;
using System.Text;
using WebhookServer.Core.Auth;
using WebhookServer.Core.Models;
using Xunit;
namespace WebhookServer.Core.Tests;
public class HmacVerifierTests
{
[Fact]
public void Compute_matches_GitHub_style_signature()
{
var body = Encoding.UTF8.GetBytes("{\"x\":1}");
var secret = "topsecret";
var hex = HmacVerifier.Compute(body, secret, HmacAlgorithm.Sha256, HmacEncoding.Hex);
// Cross-check against direct HMACSHA256 to ensure no encoding drift.
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var expected = Convert.ToHexString(hmac.ComputeHash(body)).ToLowerInvariant();
Assert.Equal(expected, hex);
}
[Fact]
public void Verify_accepts_correct_signature_with_prefix()
{
var body = Encoding.UTF8.GetBytes("hello world");
var secret = "shhh";
var sig = HmacVerifier.Compute(body, secret, HmacAlgorithm.Sha256, HmacEncoding.Hex);
var options = new HmacOptions { Secret = ProtectedString.FromPlaintext(secret) };
var result = HmacVerifier.Verify(body, $"sha256={sig}", options);
Assert.True(result.Success);
}
[Fact]
public void Verify_rejects_wrong_signature()
{
var body = Encoding.UTF8.GetBytes("payload");
var options = new HmacOptions { Secret = ProtectedString.FromPlaintext("right") };
var sig = HmacVerifier.Compute(body, "wrong", HmacAlgorithm.Sha256, HmacEncoding.Hex);
var result = HmacVerifier.Verify(body, $"sha256={sig}", options);
Assert.False(result.Success);
}
[Fact]
public void Verify_rejects_when_prefix_missing()
{
var body = Encoding.UTF8.GetBytes("payload");
var options = new HmacOptions { Secret = ProtectedString.FromPlaintext("k") };
var sig = HmacVerifier.Compute(body, "k", HmacAlgorithm.Sha256, HmacEncoding.Hex);
var result = HmacVerifier.Verify(body, sig, options); // no "sha256=" prefix
Assert.False(result.Success);
}
[Fact]
public void Verify_handles_base64_encoding()
{
var body = Encoding.UTF8.GetBytes("payload");
var secret = "abc";
var sig = HmacVerifier.Compute(body, secret, HmacAlgorithm.Sha256, HmacEncoding.Base64);
var options = new HmacOptions
{
Encoding = HmacEncoding.Base64,
Prefix = "",
Secret = ProtectedString.FromPlaintext(secret),
};
var result = HmacVerifier.Verify(body, sig, options);
Assert.True(result.Success);
}
}
@@ -0,0 +1,57 @@
using System.Net;
using WebhookServer.Core.Auth;
using Xunit;
namespace WebhookServer.Core.Tests;
public class IpAllowListTests
{
[Fact]
public void Empty_list_allows_everything()
{
var list = IpAllowList.Parse(Array.Empty<string>());
Assert.True(list.IsEmpty);
Assert.True(list.Contains(IPAddress.Parse("1.2.3.4")));
Assert.True(list.Contains(IPAddress.Parse("::1")));
}
[Fact]
public void Single_v4_matches_exact_only()
{
var list = IpAllowList.Parse(new[] { "192.168.1.10" });
Assert.True(list.Contains(IPAddress.Parse("192.168.1.10")));
Assert.False(list.Contains(IPAddress.Parse("192.168.1.11")));
}
[Fact]
public void V4_cidr_matches_inside_range()
{
var list = IpAllowList.Parse(new[] { "10.0.0.0/8" });
Assert.True(list.Contains(IPAddress.Parse("10.10.1.42")));
Assert.False(list.Contains(IPAddress.Parse("11.0.0.1")));
}
[Fact]
public void V6_cidr_matches_inside_range()
{
var list = IpAllowList.Parse(new[] { "fd00::/8" });
Assert.True(list.Contains(IPAddress.Parse("fd12:3456::1")));
Assert.False(list.Contains(IPAddress.Parse("fc00::1")));
}
[Fact]
public void Ipv4_mapped_v6_matches_v4_entry()
{
var list = IpAllowList.Parse(new[] { "127.0.0.1" });
var mapped = IPAddress.Parse("::ffff:127.0.0.1");
Assert.True(list.Contains(mapped));
}
[Fact]
public void TryParse_reports_invalid_entries()
{
var ok = IpAllowList.TryParse(new[] { "10.0.0.1", "garbage" }, out _, out var error);
Assert.False(ok);
Assert.Contains("garbage", error);
}
}
@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="xunit" Version="2.5.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\WebhookServer.Core\WebhookServer.Core.csproj" />
</ItemGroup>
</Project>