diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..2646469
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -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
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..b7e6750
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -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
diff --git a/Directory.Build.props b/Directory.Build.props
new file mode 100644
index 0000000..65b6f43
--- /dev/null
+++ b/Directory.Build.props
@@ -0,0 +1,14 @@
+
+
+
+ 0.1.0
+ Justin Paul
+ Justin Paul
+ Webhook Server
+ Copyright (c) Justin Paul
+ https://jpaul.me
+ https://github.com/recklessop/webhook-server
+ git
+
+
+
diff --git a/PLAN.md b/PLAN.md
index 563a315..7188928 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -229,6 +229,35 @@ Order in the request pipeline matters: **IP check runs before auth.** That avoid
This covers GitHub, Stripe, Slack, generic CI patterns by tweaking the four fields.
+## Service account
+
+The service itself runs fine under any account — this section is about which account makes sense for the **scripts** the service launches, since they inherit its identity.
+
+| Account | Network identity | When to use |
+|---|---|---|
+| `LocalSystem` (default) | Computer account `DOMAIN\MACHINE$` on a domain-joined host; nothing on a workgroup host | Default. Local-only scripts, or read-only AD queries on a domain-joined machine. Most powerful local account — any webhook script effectively runs as SYSTEM. |
+| `LocalService` | None — no network credentials | **Don't.** Cannot talk to AD or any other remote resource that requires Windows auth. Listed only to rule it out. |
+| `NetworkService` | Computer account, same as LocalSystem | Slightly less local privilege than LocalSystem; same network identity. Rarely worth the switch. |
+| Domain user (`DOMAIN\svc-webhookserver`) | That user | Need write/admin operations against AD (password resets, group changes, OU creates). You own password rotation. |
+| **gMSA** (`DOMAIN\svc-webhookserver$`) | That gMSA | **Recommended for AD-write workloads.** AD generates and rotates the password automatically. Requires domain functional level 2012+ and `Install-ADServiceAccount` on the host. |
+
+Install commands by account type:
+
+```powershell
+# LocalSystem (default)
+sc.exe create WebhookServer binPath= "C:\path\WebhookServer.Service.exe" start= auto
+
+# Domain user
+sc.exe create WebhookServer binPath= "..." obj= "DOMAIN\svc-webhookserver" password= "..." start= auto
+
+# gMSA — note the trailing $ and no password=
+sc.exe create WebhookServer binPath= "..." obj= "DOMAIN\svc-webhookserver$" start= auto
+```
+
+`scripts/install-service.ps1` will accept a `-ServiceAccount` parameter that defaults to `LocalSystem` and accepts a domain user or gMSA name. README will document the gMSA setup once for users who need AD writes from their hooks.
+
+The service code itself makes no assumptions about the account — DPAPI uses `LocalMachine` scope so secret decryption works under any local identity.
+
## Secret storage (DPAPI)
Endpoint `Secret` is stored in JSON as `{ "encrypted": "" }`. 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.
diff --git a/README.md b/README.md
index abd9b41..3788222 100644
--- a/README.md
+++ b/README.md
@@ -69,7 +69,31 @@ sc.exe create WebhookServer binPath= "C:\Program Files\WebhookServer\WebhookServ
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
@@ -78,7 +102,6 @@ The service reads `C:\ProgramData\WebhookServer\config.json`. Edit it through th
## Out of scope for v1
- Importing/exporting config across machines (DPAPI LocalMachine scope ties decryption to the host).
-- Outbound webhook delivery / retry queues.
- Per-endpoint rate limiting.
- Multi-user RBAC for the GUI.
- Auto-update.
diff --git a/WebhookServer.sln b/WebhookServer.sln
new file mode 100644
index 0000000..cd48989
--- /dev/null
+++ b/WebhookServer.sln
@@ -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
diff --git a/installer/webhook-server.iss b/installer/webhook-server.iss
new file mode 100644
index 0000000..36c7314
--- /dev/null
+++ b/installer/webhook-server.iss
@@ -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"
diff --git a/resources/webhook-server.ico b/resources/webhook-server.ico
new file mode 100644
index 0000000..dd4e491
Binary files /dev/null and b/resources/webhook-server.ico differ
diff --git a/resources/webhook-server.png b/resources/webhook-server.png
new file mode 100644
index 0000000..be9ef7d
Binary files /dev/null and b/resources/webhook-server.png differ
diff --git a/scripts/build-installer.ps1 b/scripts/build-installer.ps1
new file mode 100644
index 0000000..af8e060
--- /dev/null
+++ b/scripts/build-installer.ps1
@@ -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
diff --git a/scripts/deploy.ps1 b/scripts/deploy.ps1
new file mode 100644
index 0000000..1c12796
--- /dev/null
+++ b/scripts/deploy.ps1
@@ -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"
diff --git a/scripts/dev-launch.ps1 b/scripts/dev-launch.ps1
new file mode 100644
index 0000000..642d847
--- /dev/null
+++ b/scripts/dev-launch.ps1
@@ -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')"
diff --git a/scripts/generate-icons.ps1 b/scripts/generate-icons.ps1
new file mode 100644
index 0000000..d68f977
--- /dev/null
+++ b/scripts/generate-icons.ps1
@@ -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)"
diff --git a/scripts/install-service.ps1 b/scripts/install-service.ps1
new file mode 100644
index 0000000..1c8894b
--- /dev/null
+++ b/scripts/install-service.ps1
@@ -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
diff --git a/scripts/uninstall-service.ps1 b/scripts/uninstall-service.ps1
new file mode 100644
index 0000000..351bd71
--- /dev/null
+++ b/scripts/uninstall-service.ps1
@@ -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.'
diff --git a/src/WebhookServer.Core/Auth/AuthResult.cs b/src/WebhookServer.Core/Auth/AuthResult.cs
new file mode 100644
index 0000000..c2a8517
--- /dev/null
+++ b/src/WebhookServer.Core/Auth/AuthResult.cs
@@ -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);
+}
diff --git a/src/WebhookServer.Core/Auth/BearerVerifier.cs b/src/WebhookServer.Core/Auth/BearerVerifier.cs
new file mode 100644
index 0000000..587c1f2
--- /dev/null
+++ b/src/WebhookServer.Core/Auth/BearerVerifier.cs
@@ -0,0 +1,32 @@
+using System.Security.Cryptography;
+using System.Text;
+
+namespace WebhookServer.Core.Auth;
+
+public static class BearerVerifier
+{
+ private const string Prefix = "Bearer ";
+
+ ///
+ /// Compares the value of an Authorization header against an expected secret in fixed time.
+ ///
+ 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");
+ }
+}
diff --git a/src/WebhookServer.Core/Auth/HmacVerifier.cs b/src/WebhookServer.Core/Auth/HmacVerifier.cs
new file mode 100644
index 0000000..9a3dcb3
--- /dev/null
+++ b/src/WebhookServer.Core/Auth/HmacVerifier.cs
@@ -0,0 +1,76 @@
+using System.Security.Cryptography;
+using System.Text;
+using WebhookServer.Core.Models;
+
+namespace WebhookServer.Core.Auth;
+
+public static class HmacVerifier
+{
+ ///
+ /// Compute the signature string (encoded per , no prefix)
+ /// for the given body bytes and shared secret.
+ ///
+ public static string Compute(
+ ReadOnlySpan body,
+ string secret,
+ HmacAlgorithm algorithm,
+ HmacEncoding encoding)
+ {
+ var keyBytes = Encoding.UTF8.GetBytes(secret);
+ Span 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)),
+ };
+ }
+
+ ///
+ /// Verify the HMAC signature in against the
+ /// computed signature for . Strips the configured prefix
+ /// before comparing. Comparison is constant time.
+ ///
+ public static AuthResult Verify(
+ ReadOnlySpan 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");
+ }
+}
diff --git a/src/WebhookServer.Core/Auth/IpAllowList.cs b/src/WebhookServer.Core/Auth/IpAllowList.cs
new file mode 100644
index 0000000..09cc002
--- /dev/null
+++ b/src/WebhookServer.Core/Auth/IpAllowList.cs
@@ -0,0 +1,87 @@
+using System.Net;
+using System.Net.Sockets;
+
+namespace WebhookServer.Core.Auth;
+
+///
+/// Compiled allow-list of IPs and CIDR ranges. Empty list = allow all.
+///
+public sealed class IpAllowList
+{
+ private readonly List _networks;
+
+ public bool IsEmpty => _networks.Count == 0;
+
+ private IpAllowList(List 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;
+ }
+
+ ///
+ /// Parse a list of allowlist entries. Each entry may be a single IP or a CIDR.
+ /// Throws on the first invalid entry.
+ ///
+ public static IpAllowList Parse(IEnumerable entries)
+ {
+ var nets = new List();
+ 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 entries, out IpAllowList list, out string? error)
+ {
+ var nets = new List();
+ 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());
+ 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;
+ }
+}
diff --git a/src/WebhookServer.Core/Callbacks/CallbackDispatcher.cs b/src/WebhookServer.Core/Callbacks/CallbackDispatcher.cs
new file mode 100644
index 0000000..992ad4c
--- /dev/null
+++ b/src/WebhookServer.Core/Callbacks/CallbackDispatcher.cs
@@ -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;
+
+///
+/// Bounded queue of pending callback deliveries with retry + backoff. Reuses
+/// so outbound HMAC matches the inbound code path.
+///
+/// Run from a single long-running task (BackgroundService in the
+/// service host); call from anywhere. Disposing the dispatcher
+/// disposes its .
+///
+public sealed class CallbackDispatcher : IDisposable
+{
+ private const int QueueCapacity = 1024;
+ private static readonly TimeSpan MaxRetryAfter = TimeSpan.FromSeconds(60);
+
+ private readonly Channel _channel;
+ private readonly HttpClient _http;
+ private readonly ILogger? _logger;
+
+ public CallbackDispatcher(ILogger? logger = null, HttpClient? httpClient = null)
+ {
+ _logger = logger;
+ _channel = Channel.CreateBounded(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();
+}
diff --git a/src/WebhookServer.Core/Callbacks/CallbackEnvelope.cs b/src/WebhookServer.Core/Callbacks/CallbackEnvelope.cs
new file mode 100644
index 0000000..13148b7
--- /dev/null
+++ b/src/WebhookServer.Core/Callbacks/CallbackEnvelope.cs
@@ -0,0 +1,15 @@
+using WebhookServer.Core.Models;
+
+namespace WebhookServer.Core.Callbacks;
+
+///
+/// Internal queue item pairing a payload with the resolved
+/// for the endpoint. The dispatcher reads from a channel of these.
+///
+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; }
+}
diff --git a/src/WebhookServer.Core/Callbacks/CallbackPayload.cs b/src/WebhookServer.Core/Callbacks/CallbackPayload.cs
new file mode 100644
index 0000000..b52c207
--- /dev/null
+++ b/src/WebhookServer.Core/Callbacks/CallbackPayload.cs
@@ -0,0 +1,22 @@
+using System.Text.Json.Serialization;
+
+namespace WebhookServer.Core.Callbacks;
+
+///
+/// JSON body POSTed to a configured outbound callback URL.
+///
+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; }
+}
diff --git a/src/WebhookServer.Core/Execution/ArgTemplateRenderer.cs b/src/WebhookServer.Core/Execution/ArgTemplateRenderer.cs
new file mode 100644
index 0000000..4d9b454
--- /dev/null
+++ b/src/WebhookServer.Core/Execution/ArgTemplateRenderer.cs
@@ -0,0 +1,116 @@
+using System.Text.Json.Nodes;
+
+namespace WebhookServer.Core.Execution;
+
+///
+/// Resolves {{path}} tokens against an . 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.
+///
+public static class ArgTemplateRenderer
+{
+ public static List Render(string? template, ExecutionContext ctx)
+ {
+ var args = new List();
+ 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 map, string key)
+ {
+ foreach (var kvp in map)
+ {
+ if (string.Equals(kvp.Key, key, StringComparison.OrdinalIgnoreCase))
+ return kvp.Value;
+ }
+ return "";
+ }
+}
diff --git a/src/WebhookServer.Core/Execution/ConcurrencyGate.cs b/src/WebhookServer.Core/Execution/ConcurrencyGate.cs
new file mode 100644
index 0000000..c6f7cd3
--- /dev/null
+++ b/src/WebhookServer.Core/Execution/ConcurrencyGate.cs
@@ -0,0 +1,37 @@
+using System.Collections.Concurrent;
+
+namespace WebhookServer.Core.Execution;
+
+///
+/// Holds one 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.
+///
+public sealed class ConcurrencyGate
+{
+ private readonly ConcurrentDictionary _gates = new();
+
+ public async Task 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();
+ }
+ }
+}
diff --git a/src/WebhookServer.Core/Execution/ExecutionContext.cs b/src/WebhookServer.Core/Execution/ExecutionContext.cs
new file mode 100644
index 0000000..ff9feb3
--- /dev/null
+++ b/src/WebhookServer.Core/Execution/ExecutionContext.cs
@@ -0,0 +1,18 @@
+using System.Text.Json.Nodes;
+
+namespace WebhookServer.Core.Execution;
+
+///
+/// All data the executor needs from the inbound HTTP request.
+///
+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 Headers { get; init; }
+ public required IReadOnlyDictionary Query { get; init; }
+ public required IReadOnlyDictionary Route { get; init; }
+}
diff --git a/src/WebhookServer.Core/Execution/ExecutionResult.cs b/src/WebhookServer.Core/Execution/ExecutionResult.cs
new file mode 100644
index 0000000..fccf33d
--- /dev/null
+++ b/src/WebhookServer.Core/Execution/ExecutionResult.cs
@@ -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;
+}
diff --git a/src/WebhookServer.Core/Execution/IExecutor.cs b/src/WebhookServer.Core/Execution/IExecutor.cs
new file mode 100644
index 0000000..df4b06e
--- /dev/null
+++ b/src/WebhookServer.Core/Execution/IExecutor.cs
@@ -0,0 +1,8 @@
+using WebhookServer.Core.Models;
+
+namespace WebhookServer.Core.Execution;
+
+public interface IExecutor
+{
+ Task RunAsync(EndpointConfig endpoint, ExecutionContext ctx, CancellationToken ct);
+}
diff --git a/src/WebhookServer.Core/Execution/Native/InteractiveProcessLauncher.cs b/src/WebhookServer.Core/Execution/Native/InteractiveProcessLauncher.cs
new file mode 100644
index 0000000..b9229ba
--- /dev/null
+++ b/src/WebhookServer.Core/Execution/Native/InteractiveProcessLauncher.cs
@@ -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;
+
+///
+/// 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 Process.Start 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 WTSQueryUserToken.
+///
+[SupportedOSPlatform("windows")]
+internal static class InteractiveProcessLauncher
+{
+ public sealed class LaunchOptions
+ {
+ public required string FileName { get; init; }
+ public required IReadOnlyList Arguments { get; init; }
+ public string? WorkingDirectory { get; init; }
+ public IReadOnlyDictionary? 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 { }
+ }
+ }
+
+ ///
+ /// 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).
+ ///
+ 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); }
+ }
+
+ ///
+ /// 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.
+ ///
+ 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(),
+ 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 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(),
+ 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 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();
+ 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(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;
+ }
+
+ ///
+ /// Build a Windows command line string from a filename + arg list using the
+ /// quoting rules consumed by CommandLineToArgvW.
+ ///
+ private static string BuildCommandLine(string fileName, IReadOnlyList 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('"');
+ }
+}
diff --git a/src/WebhookServer.Core/Execution/Native/NativeMethods.cs b/src/WebhookServer.Core/Execution/Native/NativeMethods.cs
new file mode 100644
index 0000000..ae52b72
--- /dev/null
+++ b/src/WebhookServer.Core/Execution/Native/NativeMethods.cs
@@ -0,0 +1,157 @@
+using System.ComponentModel;
+using System.Runtime.InteropServices;
+using System.Runtime.Versioning;
+
+namespace WebhookServer.Core.Execution.Native;
+
+///
+/// Win32 P/Invoke layer for launching processes in another user's session.
+/// Used by ; not intended for general use.
+///
+[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");
+}
diff --git a/src/WebhookServer.Core/Execution/ProcessExecutor.cs b/src/WebhookServer.Core/Execution/ProcessExecutor.cs
new file mode 100644
index 0000000..3d29aff
--- /dev/null
+++ b/src/WebhookServer.Core/Execution/ProcessExecutor.cs
@@ -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
+{
+ /// Per-stream cap on captured output (excess is dropped and StdoutTruncated set).
+ public const int MaxOutputBytes = 1 * 1024 * 1024;
+
+ public async Task 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 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 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 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(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,
+ };
+}
diff --git a/src/WebhookServer.Core/Ipc/AdminProtocol.cs b/src/WebhookServer.Core/Ipc/AdminProtocol.cs
new file mode 100644
index 0000000..5ca1c4e
--- /dev/null
+++ b/src/WebhookServer.Core/Ipc/AdminProtocol.cs
@@ -0,0 +1,107 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace WebhookServer.Core.Ipc;
+
+///
+/// Operation discriminators for the named-pipe admin protocol. Request payload shape
+/// is op-specific; the handler is responsible for binding
+/// to the right concrete type.
+///
+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) },
+ };
+}
diff --git a/src/WebhookServer.Core/Ipc/PipeFraming.cs b/src/WebhookServer.Core/Ipc/PipeFraming.cs
new file mode 100644
index 0000000..199681e
--- /dev/null
+++ b/src/WebhookServer.Core/Ipc/PipeFraming.cs
@@ -0,0 +1,29 @@
+using System.Text;
+using System.Text.Json;
+
+namespace WebhookServer.Core.Ipc;
+
+///
+/// Line-delimited JSON over a stream. One JSON object per line, terminated by '\n'.
+///
+public static class PipeFraming
+{
+ public static async Task WriteAsync(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 ReadAsync(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(line, AdminProtocol.JsonOptions);
+ }
+
+ public static StreamReader CreateReader(Stream stream) =>
+ new(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, bufferSize: 4096, leaveOpen: true);
+}
diff --git a/src/WebhookServer.Core/Ipc/PipeSecurityFactory.cs b/src/WebhookServer.Core/Ipc/PipeSecurityFactory.cs
new file mode 100644
index 0000000..0b06422
--- /dev/null
+++ b/src/WebhookServer.Core/Ipc/PipeSecurityFactory.cs
@@ -0,0 +1,33 @@
+using System.IO.Pipes;
+using System.Runtime.Versioning;
+using System.Security.AccessControl;
+using System.Security.Principal;
+
+namespace WebhookServer.Core.Ipc;
+
+///
+/// Builds a 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.
+///
+[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;
+ }
+}
diff --git a/src/WebhookServer.Core/Models/BearerOptions.cs b/src/WebhookServer.Core/Models/BearerOptions.cs
new file mode 100644
index 0000000..3815f1b
--- /dev/null
+++ b/src/WebhookServer.Core/Models/BearerOptions.cs
@@ -0,0 +1,6 @@
+namespace WebhookServer.Core.Models;
+
+public sealed class BearerOptions
+{
+ public ProtectedString Secret { get; set; } = new();
+}
diff --git a/src/WebhookServer.Core/Models/CallbackConfig.cs b/src/WebhookServer.Core/Models/CallbackConfig.cs
new file mode 100644
index 0000000..2413ec7
--- /dev/null
+++ b/src/WebhookServer.Core/Models/CallbackConfig.cs
@@ -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;
+}
diff --git a/src/WebhookServer.Core/Models/DataPassingOptions.cs b/src/WebhookServer.Core/Models/DataPassingOptions.cs
new file mode 100644
index 0000000..26a66e0
--- /dev/null
+++ b/src/WebhookServer.Core/Models/DataPassingOptions.cs
@@ -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; }
+
+ ///
+ /// Whitespace-separated list of template tokens; each rendered token becomes one argv entry.
+ /// Only used when is true.
+ ///
+ public string? ArgTemplateString { get; set; }
+}
diff --git a/src/WebhookServer.Core/Models/EndpointConfig.cs b/src/WebhookServer.Core/Models/EndpointConfig.cs
new file mode 100644
index 0000000..3c9b415
--- /dev/null
+++ b/src/WebhookServer.Core/Models/EndpointConfig.cs
@@ -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 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;
+
+ /// Path to a script file (.ps1, .bat, .cmd) when applicable.
+ public string? ScriptPath { get; set; }
+
+ /// Inline command body when no script file is used (PowerShell -Command, cmd /c).
+ public string? InlineCommand { get; set; }
+
+ /// Path to the executable when ExecutorType = Executable.
+ public string? ExecutablePath { get; set; }
+
+ /// Static argv prefix for Executable mode; the rendered ArgTemplate appends after.
+ public List 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;
+
+ /// If true, a non-zero process exit produces 502 in sync mode (default true).
+ public bool FailOnNonZeroExit { get; set; } = true;
+
+ /// If true, requests are processed one at a time per endpoint.
+ public bool Serialize { get; set; }
+
+ public CallbackConfig? Callback { get; set; }
+
+ public RunAsConfig? RunAs { get; set; }
+}
diff --git a/src/WebhookServer.Core/Models/Enums.cs b/src/WebhookServer.Core/Models/Enums.cs
new file mode 100644
index 0000000..429c8bf
--- /dev/null
+++ b/src/WebhookServer.Core/Models/Enums.cs
@@ -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
+{
+ /// Run as whatever account the service itself runs under (default).
+ Service = 0,
+
+ /// Run as a specific username + password (batch logon, no UI).
+ SpecificUser = 1,
+
+ ///
+ /// Run in the active console session under whoever is logged in at the keyboard.
+ /// Lets hooks pop interactive UI on the user's desktop.
+ ///
+ InteractiveUser = 2,
+}
diff --git a/src/WebhookServer.Core/Models/HmacOptions.cs b/src/WebhookServer.Core/Models/HmacOptions.cs
new file mode 100644
index 0000000..de24658
--- /dev/null
+++ b/src/WebhookServer.Core/Models/HmacOptions.cs
@@ -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();
+}
diff --git a/src/WebhookServer.Core/Models/HttpsBinding.cs b/src/WebhookServer.Core/Models/HttpsBinding.cs
new file mode 100644
index 0000000..4a2f3cd
--- /dev/null
+++ b/src/WebhookServer.Core/Models/HttpsBinding.cs
@@ -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;
+
+ /// Path to a .pfx file when Kind = PfxFile.
+ public string? PfxPath { get; set; }
+ public ProtectedString? PfxPassword { get; set; }
+
+ /// Cert thumbprint when Kind = CertStoreThumbprint.
+ public string? Thumbprint { get; set; }
+ public StoreLocation StoreLocation { get; set; } = StoreLocation.LocalMachine;
+}
diff --git a/src/WebhookServer.Core/Models/ProtectedString.cs b/src/WebhookServer.Core/Models/ProtectedString.cs
new file mode 100644
index 0000000..1039114
--- /dev/null
+++ b/src/WebhookServer.Core/Models/ProtectedString.cs
@@ -0,0 +1,29 @@
+using System.Text.Json.Serialization;
+
+namespace WebhookServer.Core.Models;
+
+///
+/// A secret value. is the persistent (DPAPI-protected) form;
+/// 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: encrypts
+/// then clears before writing.
+///
+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 };
+}
diff --git a/src/WebhookServer.Core/Models/RunAsConfig.cs b/src/WebhookServer.Core/Models/RunAsConfig.cs
new file mode 100644
index 0000000..47d648d
--- /dev/null
+++ b/src/WebhookServer.Core/Models/RunAsConfig.cs
@@ -0,0 +1,21 @@
+namespace WebhookServer.Core.Models;
+
+public sealed class RunAsConfig
+{
+ public RunAsMode Mode { get; set; } = RunAsMode.Service;
+
+ ///
+ /// "DOMAIN\user" or "user@upn" or just "user" (local). Required when
+ /// is .
+ ///
+ public string? Username { get; set; }
+
+ /// DPAPI-protected password for SpecificUser mode.
+ public ProtectedString? Password { get; set; }
+
+ ///
+ /// When true, load the user's profile (HKCU + AppData) before running.
+ /// Slower; only needed for hooks that read user-scope settings.
+ ///
+ public bool LoadProfile { get; set; }
+}
diff --git a/src/WebhookServer.Core/Models/ServerConfig.cs b/src/WebhookServer.Core/Models/ServerConfig.cs
new file mode 100644
index 0000000..2bef02b
--- /dev/null
+++ b/src/WebhookServer.Core/Models/ServerConfig.cs
@@ -0,0 +1,29 @@
+namespace WebhookServer.Core.Models;
+
+public sealed class ServerConfig
+{
+ public int HttpPort { get; set; } = 8080;
+ public HttpsBinding? HttpsBinding { get; set; }
+
+ ///
+ /// IP addresses Kestrel binds to. Empty = listen on all interfaces (default).
+ /// Non-empty = listen only on the named addresses.
+ ///
+ public List BindAddresses { get; set; } = new();
+
+ ///
+ /// Hostname or IP that the GUI uses when constructing webhook URLs to display.
+ /// Null = "localhost". Has no effect on what Kestrel actually accepts.
+ ///
+ public string? DisplayHost { get; set; }
+
+ ///
+ /// IPs/CIDRs allowed to set X-Forwarded-For. Empty = forwarded headers are ignored
+ /// and the direct connection IP is always used.
+ ///
+ public List TrustedProxies { get; set; } = new();
+
+ public int LogRetentionDays { get; set; } = 14;
+
+ public List Endpoints { get; set; } = new();
+}
diff --git a/src/WebhookServer.Core/Storage/ConfigJson.cs b/src/WebhookServer.Core/Storage/ConfigJson.cs
new file mode 100644
index 0000000..fda4dce
--- /dev/null
+++ b/src/WebhookServer.Core/Storage/ConfigJson.cs
@@ -0,0 +1,27 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace WebhookServer.Core.Storage;
+
+///
+/// Shared JSON serialization options used for persisting
+/// and for IPC payloads. Keeps formatting and naming consistent.
+///
+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) },
+ };
+}
diff --git a/src/WebhookServer.Core/Storage/ConfigStore.cs b/src/WebhookServer.Core/Storage/ConfigStore.cs
new file mode 100644
index 0000000..2f6daa3
--- /dev/null
+++ b/src/WebhookServer.Core/Storage/ConfigStore.cs
@@ -0,0 +1,151 @@
+using System.Runtime.Versioning;
+using System.Text.Json;
+using WebhookServer.Core.Models;
+
+namespace WebhookServer.Core.Storage;
+
+///
+/// Loads and saves JSON. Round-trips secrets through DPAPI:
+/// on save, any secret that has Plaintext but no Encrypted is protected first; on load
+/// (when is called) all Encrypted blobs are unprotected
+/// into Plaintext for in-memory use.
+///
+[SupportedOSPlatform("windows")]
+public sealed class ConfigStore
+{
+ public string Path { get; }
+
+ public ConfigStore(string path)
+ {
+ Path = path;
+ }
+
+ public async Task LoadAsync(CancellationToken ct = default)
+ {
+ if (!File.Exists(Path))
+ return new ServerConfig();
+
+ await using var fs = File.OpenRead(Path);
+ var cfg = await JsonSerializer.DeserializeAsync(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);
+ }
+}
diff --git a/src/WebhookServer.Core/Storage/DpapiSecret.cs b/src/WebhookServer.Core/Storage/DpapiSecret.cs
new file mode 100644
index 0000000..1fd3787
--- /dev/null
+++ b/src/WebhookServer.Core/Storage/DpapiSecret.cs
@@ -0,0 +1,30 @@
+using System.Runtime.Versioning;
+using System.Security.Cryptography;
+using System.Text;
+
+namespace WebhookServer.Core.Storage;
+
+///
+/// DPAPI helpers using 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.
+///
+[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);
+ }
+}
diff --git a/src/WebhookServer.Core/WebhookServer.Core.csproj b/src/WebhookServer.Core/WebhookServer.Core.csproj
new file mode 100644
index 0000000..bb5adad
--- /dev/null
+++ b/src/WebhookServer.Core/WebhookServer.Core.csproj
@@ -0,0 +1,15 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
diff --git a/src/WebhookServer.Gui/App.xaml b/src/WebhookServer.Gui/App.xaml
new file mode 100644
index 0000000..e2bc87a
--- /dev/null
+++ b/src/WebhookServer.Gui/App.xaml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/WebhookServer.Gui/App.xaml.cs b/src/WebhookServer.Gui/App.xaml.cs
new file mode 100644
index 0000000..845e1e0
--- /dev/null
+++ b/src/WebhookServer.Gui/App.xaml.cs
@@ -0,0 +1,5 @@
+namespace WebhookServer.Gui;
+
+public partial class App : Application
+{
+}
diff --git a/src/WebhookServer.Gui/AssemblyInfo.cs b/src/WebhookServer.Gui/AssemblyInfo.cs
new file mode 100644
index 0000000..cc29e7f
--- /dev/null
+++ b/src/WebhookServer.Gui/AssemblyInfo.cs
@@ -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)
+)]
diff --git a/src/WebhookServer.Gui/Converters/Converters.cs b/src/WebhookServer.Gui/Converters/Converters.cs
new file mode 100644
index 0000000..e346830
--- /dev/null
+++ b/src/WebhookServer.Gui/Converters/Converters.cs
@@ -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();
+}
diff --git a/src/WebhookServer.Gui/GlobalUsings.cs b/src/WebhookServer.Gui/GlobalUsings.cs
new file mode 100644
index 0000000..59cef05
--- /dev/null
+++ b/src/WebhookServer.Gui/GlobalUsings.cs
@@ -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;
diff --git a/src/WebhookServer.Gui/MainWindow.xaml b/src/WebhookServer.Gui/MainWindow.xaml
new file mode 100644
index 0000000..a2ff565
--- /dev/null
+++ b/src/WebhookServer.Gui/MainWindow.xaml
@@ -0,0 +1,157 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/WebhookServer.Gui/MainWindow.xaml.cs b/src/WebhookServer.Gui/MainWindow.xaml.cs
new file mode 100644
index 0000000..de2a239
--- /dev/null
+++ b/src/WebhookServer.Gui/MainWindow.xaml.cs
@@ -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);
+ }
+}
diff --git a/src/WebhookServer.Gui/Services/AdminPipeClient.cs b/src/WebhookServer.Gui/Services/AdminPipeClient.cs
new file mode 100644
index 0000000..4ce804e
--- /dev/null
+++ b/src/WebhookServer.Gui/Services/AdminPipeClient.cs
@@ -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;
+
+///
+/// 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.
+///
+[SupportedOSPlatform("windows")]
+public sealed class AdminPipeClient
+{
+ public TimeSpan ConnectTimeout { get; init; } = TimeSpan.FromSeconds(5);
+
+ public async Task 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(reader, ct).ConfigureAwait(false);
+ return response ?? AdminResponse.Failure("empty response from service");
+ }
+
+ public async Task InvokeAsync(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(AdminProtocol.JsonOptions);
+ }
+
+ public Task PingAsync(CancellationToken ct = default) =>
+ InvokeAsync(AdminOps.Ping, null, ct);
+
+ public Task GetStatusAsync(CancellationToken ct = default) =>
+ InvokeAsync(AdminOps.GetStatus, null, ct);
+
+ public Task GetConfigAsync(CancellationToken ct = default) =>
+ InvokeAsync(AdminOps.GetConfig, null, ct);
+
+ public Task CreateEndpointAsync(EndpointConfig endpoint, CancellationToken ct = default) =>
+ InvokeAsync(AdminOps.CreateEndpoint, endpoint, ct);
+
+ public Task UpdateEndpointAsync(EndpointConfig endpoint, CancellationToken ct = default) =>
+ InvokeAsync(AdminOps.UpdateEndpoint, endpoint, ct);
+
+ public Task DeleteEndpointAsync(Guid id, CancellationToken ct = default) =>
+ InvokeAsync(AdminOps.DeleteEndpoint, new DeleteEndpointArgs { Id = id }, ct);
+
+ public Task SetEndpointEnabledAsync(Guid id, bool enabled, CancellationToken ct = default) =>
+ InvokeAsync(enabled ? AdminOps.EnableEndpoint : AdminOps.DisableEndpoint, new EndpointToggle { Id = id }, ct);
+
+ public Task BindHttpsAsync(HttpsBinding? binding, CancellationToken ct = default) =>
+ InvokeAsync(AdminOps.BindHttps, binding, ct);
+
+ public Task RestartListenerAsync(CancellationToken ct = default) =>
+ InvokeAsync(AdminOps.RestartListener, null, ct);
+
+ public async Task> 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();
+ var lst = resp.Data.Value.GetProperty("lines").Deserialize>(AdminProtocol.JsonOptions);
+ return lst ?? new List();
+ }
+
+ public async Task> ListBackupsAsync(CancellationToken ct = default)
+ {
+ var resp = await InvokeAsync(AdminOps.ListBackups, null, ct).ConfigureAwait(false);
+ if (!resp.Ok || resp.Data is null) return new List();
+ var lst = resp.Data.Value.GetProperty("backups").Deserialize>(AdminProtocol.JsonOptions);
+ return lst ?? new List();
+ }
+
+ public Task RestoreBackupAsync(string fileName, CancellationToken ct = default) =>
+ InvokeAsync(AdminOps.RestoreBackup, new RestoreBackupArgs { FileName = fileName }, ct);
+
+ public Task ImportConfigAsync(ServerConfig config, CancellationToken ct = default) =>
+ InvokeAsync(AdminOps.ImportConfig, config, ct);
+}
diff --git a/src/WebhookServer.Gui/Services/TrayIcon.cs b/src/WebhookServer.Gui/Services/TrayIcon.cs
new file mode 100644
index 0000000..7cedfa5
--- /dev/null
+++ b/src/WebhookServer.Gui/Services/TrayIcon.cs
@@ -0,0 +1,86 @@
+using System.Drawing;
+using System.Runtime.Versioning;
+using System.Windows;
+using System.Windows.Forms;
+
+namespace WebhookServer.Gui.Services;
+
+///
+/// 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.
+///
+[SupportedOSPlatform("windows")]
+public sealed class TrayIcon : IDisposable
+{
+ private readonly NotifyIcon _icon;
+ private readonly Func _resolveMainWindow;
+ private readonly Func _restartServiceAsync;
+
+ public TrayIcon(Func resolveMainWindow, Func 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();
+ }
+}
diff --git a/src/WebhookServer.Gui/ViewModels/EndpointEditorViewModel.cs b/src/WebhookServer.Gui/ViewModels/EndpointEditorViewModel.cs
new file mode 100644
index 0000000..bafcd7a
--- /dev/null
+++ b/src/WebhookServer.Gui/ViewModels/EndpointEditorViewModel.cs
@@ -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(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));
+
+ ///
+ /// Proxy for that emits change notifications
+ /// for the visibility flags so the bearer/HMAC sections show/hide reactively.
+ ///
+ 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;
+}
diff --git a/src/WebhookServer.Gui/ViewModels/MainViewModel.cs b/src/WebhookServer.Gui/ViewModels/MainViewModel.cs
new file mode 100644
index 0000000..69b8d11
--- /dev/null
+++ b/src/WebhookServer.Gui/ViewModels/MainViewModel.cs
@@ -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 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 _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(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));
+ }
+}
diff --git a/src/WebhookServer.Gui/ViewModels/ServerSettingsViewModel.cs b/src/WebhookServer.Gui/ViewModels/ServerSettingsViewModel.cs
new file mode 100644
index 0000000..4b855ad
--- /dev/null
+++ b/src/WebhookServer.Gui/ViewModels/ServerSettingsViewModel.cs
@@ -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";
+
+ /// One row per detected local IPv4/IPv6 address. Bound for "listen on" checkboxes.
+ public ObservableCollection Addresses { get; } = new();
+
+ /// Suggestions for the Display URL host dropdown (detected IPs + localhost + machine name).
+ public ObservableCollection 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(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 TrustedProxiesList =>
+ (TrustedProxiesText ?? "").Split(new[] { '\r', '\n', ',' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
+
+ public List BindAddressesList =>
+ ListenAllInterfaces
+ ? new List()
+ : 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(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;
+}
diff --git a/src/WebhookServer.Gui/Views/AboutDialog.xaml b/src/WebhookServer.Gui/Views/AboutDialog.xaml
new file mode 100644
index 0000000..455f611
--- /dev/null
+++ b/src/WebhookServer.Gui/Views/AboutDialog.xaml
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ A Windows-native webhook server that runs PowerShell, cmd, or arbitrary
+ executables in response to incoming HTTP requests.
+
+
+
+
+
+
+
+
+ https://jpaul.me
+
+
+ github.com/recklessop/webhook-server
+
+
+
+
+
+
diff --git a/src/WebhookServer.Gui/Views/AboutDialog.xaml.cs b/src/WebhookServer.Gui/Views/AboutDialog.xaml.cs
new file mode 100644
index 0000000..5a66ed5
--- /dev/null
+++ b/src/WebhookServer.Gui/Views/AboutDialog.xaml.cs
@@ -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()?.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();
+}
diff --git a/src/WebhookServer.Gui/Views/EndpointEditor.xaml b/src/WebhookServer.Gui/Views/EndpointEditor.xaml
new file mode 100644
index 0000000..0d3ef95
--- /dev/null
+++ b/src/WebhookServer.Gui/Views/EndpointEditor.xaml
@@ -0,0 +1,205 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/WebhookServer.Gui/Views/EndpointEditor.xaml.cs b/src/WebhookServer.Gui/Views/EndpointEditor.xaml.cs
new file mode 100644
index 0000000..63248b7
--- /dev/null
+++ b/src/WebhookServer.Gui/Views/EndpointEditor.xaml.cs
@@ -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 */ }
+ }
+}
diff --git a/src/WebhookServer.Gui/Views/ServerSettings.xaml b/src/WebhookServer.Gui/Views/ServerSettings.xaml
new file mode 100644
index 0000000..75533dc
--- /dev/null
+++ b/src/WebhookServer.Gui/Views/ServerSettings.xaml
@@ -0,0 +1,111 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/WebhookServer.Gui/Views/ServerSettings.xaml.cs b/src/WebhookServer.Gui/Views/ServerSettings.xaml.cs
new file mode 100644
index 0000000..ca8c982
--- /dev/null
+++ b/src/WebhookServer.Gui/Views/ServerSettings.xaml.cs
@@ -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;
+ }
+}
diff --git a/src/WebhookServer.Gui/WebhookServer.Gui.csproj b/src/WebhookServer.Gui/WebhookServer.Gui.csproj
new file mode 100644
index 0000000..8308673
--- /dev/null
+++ b/src/WebhookServer.Gui/WebhookServer.Gui.csproj
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+ WinExe
+ net8.0-windows
+ enable
+ enable
+ true
+ true
+ ..\..\resources\webhook-server.ico
+ Webhook Server
+
+
+
+
+
+
+
+
diff --git a/src/WebhookServer.Service/AdminPipeServer.cs b/src/WebhookServer.Service/AdminPipeServer.cs
new file mode 100644
index 0000000..811b0a1
--- /dev/null
+++ b/src/WebhookServer.Service/AdminPipeServer.cs
@@ -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 _logger;
+
+ public AdminPipeServer(ServiceState state, IHostApplicationLifetime lifetime, ILogger 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(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 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(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(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(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(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(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(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(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(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(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 ListBackups()
+ {
+ var dir = Path.Combine(ServicePaths.DataRoot, "backups");
+ if (!Directory.Exists(dir)) return new List();
+ 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 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(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(json, ConfigJson.Compact)!;
+ }
+
+ private static T? DeserializeData(AdminRequest request)
+ {
+ if (request.Data is not { ValueKind: not JsonValueKind.Null and not JsonValueKind.Undefined } element)
+ return default;
+ return element.Deserialize(AdminProtocol.JsonOptions);
+ }
+
+ ///
+ /// 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.
+ ///
+ private static ServerConfig SafeSnapshotForWire(ServerConfig snap)
+ {
+ var json = JsonSerializer.Serialize(snap, ConfigJson.Compact);
+ return JsonSerializer.Deserialize(json, ConfigJson.Compact)!;
+ }
+
+ ///
+ /// When the GUI sends an 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.
+ ///
+ 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 ReadTailLines(int count)
+ {
+ try
+ {
+ var dir = ServicePaths.LogsDir;
+ if (!Directory.Exists(dir)) return new List();
+ var latest = Directory.GetFiles(dir, "webhook-*.log")
+ .OrderByDescending(p => p)
+ .FirstOrDefault();
+ if (latest is null) return new List();
+
+ using var fs = new FileStream(latest, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
+ using var sr = new StreamReader(fs);
+ var lines = new LinkedList();
+ 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();
+ }
+ }
+}
diff --git a/src/WebhookServer.Service/CallbackBackgroundService.cs b/src/WebhookServer.Service/CallbackBackgroundService.cs
new file mode 100644
index 0000000..52e6fd2
--- /dev/null
+++ b/src/WebhookServer.Service/CallbackBackgroundService.cs
@@ -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);
+}
diff --git a/src/WebhookServer.Service/Program.cs b/src/WebhookServer.Service/Program.cs
new file mode 100644
index 0000000..8eefe98
--- /dev/null
+++ b/src/WebhookServer.Service/Program.cs
@@ -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();
+ builder.Services.AddSingleton();
+ builder.Services.AddSingleton();
+ builder.Services.AddSingleton(sp =>
+ new CallbackDispatcher(sp.GetService>()));
+ builder.Services.AddSingleton();
+ builder.Services.AddHostedService();
+ builder.Services.AddHostedService();
+
+ var app = builder.Build();
+
+ var state = app.Services.GetRequiredService();
+ await state.LoadAsync().ConfigureAwait(false);
+
+ var lifetime = app.Services.GetRequiredService();
+ 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();
+ 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));
+}
diff --git a/src/WebhookServer.Service/ServicePaths.cs b/src/WebhookServer.Service/ServicePaths.cs
new file mode 100644
index 0000000..166627e
--- /dev/null
+++ b/src/WebhookServer.Service/ServicePaths.cs
@@ -0,0 +1,16 @@
+namespace WebhookServer.Service;
+
+///
+/// Standard locations for runtime files (config + logs). Centralised so they're easy
+/// to override in tests and inspect in one place.
+///
+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");
+}
diff --git a/src/WebhookServer.Service/ServiceState.cs b/src/WebhookServer.Service/ServiceState.cs
new file mode 100644
index 0000000..13474de
--- /dev/null
+++ b/src/WebhookServer.Service/ServiceState.cs
@@ -0,0 +1,117 @@
+using System.Runtime.Versioning;
+using WebhookServer.Core.Auth;
+using WebhookServer.Core.Models;
+using WebhookServer.Core.Storage;
+
+namespace WebhookServer.Service;
+
+///
+/// In-memory authoritative copy of the current . 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.
+///
+[SupportedOSPlatform("windows")]
+public sealed class ServiceState
+{
+ private readonly ConfigStore _store;
+ private readonly object _lock = new();
+ private ServerConfig _config = new();
+ private Dictionary _bySlug = new(StringComparer.Ordinal);
+ private Dictionary _allowLists = new();
+ private IpAllowList _trustedProxies = IpAllowList.Parse(Array.Empty());
+
+ 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());
+ }
+ }
+
+ 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(StringComparer.Ordinal);
+ var allow = new Dictionary();
+
+ 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;
+ }
+}
diff --git a/src/WebhookServer.Service/WebhookRouter.cs b/src/WebhookServer.Service/WebhookRouter.cs
new file mode 100644
index 0000000..5095b73
--- /dev/null
+++ b/src/WebhookServer.Service/WebhookRouter.cs
@@ -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 _logger;
+
+ public WebhookRouter(
+ ServiceState state,
+ IExecutor executor,
+ ConcurrencyGate gate,
+ CallbackDispatcher callbacks,
+ ILogger 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(StringComparer.OrdinalIgnoreCase);
+ foreach (var (key, value) in http.Request.Headers)
+ headers[key] = value.ToString();
+
+ var query = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var (key, value) in http.Request.Query)
+ query[key] = value.ToString();
+
+ var route = new Dictionary(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 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;
+ }
+}
diff --git a/src/WebhookServer.Service/WebhookServer.Service.csproj b/src/WebhookServer.Service/WebhookServer.Service.csproj
new file mode 100644
index 0000000..29ef427
--- /dev/null
+++ b/src/WebhookServer.Service/WebhookServer.Service.csproj
@@ -0,0 +1,21 @@
+
+
+
+ net8.0
+ enable
+ enable
+ dotnet-WebhookServer.Service-57f4579b-6131-4fab-a6ad-2865b038cc2e
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/WebhookServer.Service/appsettings.json b/src/WebhookServer.Service/appsettings.json
new file mode 100644
index 0000000..b2dcdb6
--- /dev/null
+++ b/src/WebhookServer.Service/appsettings.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.Hosting.Lifetime": "Information"
+ }
+ }
+}
diff --git a/tests/WebhookServer.Core.Tests/ArgTemplateRendererTests.cs b/tests/WebhookServer.Core.Tests/ArgTemplateRendererTests.cs
new file mode 100644
index 0000000..18a436b
--- /dev/null
+++ b/tests/WebhookServer.Core.Tests/ArgTemplateRendererTests.cs
@@ -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? headers = null, Dictionary? 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(StringComparer.OrdinalIgnoreCase),
+ Query = query ?? new Dictionary(StringComparer.OrdinalIgnoreCase),
+ Route = new Dictionary(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(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);
+ }
+}
diff --git a/tests/WebhookServer.Core.Tests/BearerVerifierTests.cs b/tests/WebhookServer.Core.Tests/BearerVerifierTests.cs
new file mode 100644
index 0000000..095f697
--- /dev/null
+++ b/tests/WebhookServer.Core.Tests/BearerVerifierTests.cs
@@ -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);
+}
diff --git a/tests/WebhookServer.Core.Tests/ConfigStoreTests.cs b/tests/WebhookServer.Core.Tests/ConfigStoreTests.cs
new file mode 100644
index 0000000..b43fded
--- /dev/null
+++ b/tests/WebhookServer.Core.Tests/ConfigStoreTests.cs
@@ -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);
+ }
+ }
+}
diff --git a/tests/WebhookServer.Core.Tests/DpapiSecretTests.cs b/tests/WebhookServer.Core.Tests/DpapiSecretTests.cs
new file mode 100644
index 0000000..ed12aa6
--- /dev/null
+++ b/tests/WebhookServer.Core.Tests/DpapiSecretTests.cs
@@ -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("")));
+ }
+}
diff --git a/tests/WebhookServer.Core.Tests/HmacVerifierTests.cs b/tests/WebhookServer.Core.Tests/HmacVerifierTests.cs
new file mode 100644
index 0000000..3dc83b4
--- /dev/null
+++ b/tests/WebhookServer.Core.Tests/HmacVerifierTests.cs
@@ -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);
+ }
+}
diff --git a/tests/WebhookServer.Core.Tests/IpAllowListTests.cs b/tests/WebhookServer.Core.Tests/IpAllowListTests.cs
new file mode 100644
index 0000000..e47618d
--- /dev/null
+++ b/tests/WebhookServer.Core.Tests/IpAllowListTests.cs
@@ -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());
+ 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);
+ }
+}
diff --git a/tests/WebhookServer.Core.Tests/WebhookServer.Core.Tests.csproj b/tests/WebhookServer.Core.Tests/WebhookServer.Core.Tests.csproj
new file mode 100644
index 0000000..275a360
--- /dev/null
+++ b/tests/WebhookServer.Core.Tests/WebhookServer.Core.Tests.csproj
@@ -0,0 +1,27 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+