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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +