From 2f61b342af2512a2398853254ea66282528fc743 Mon Sep 17 00:00:00 2001 From: Justin Paul Date: Thu, 7 May 2026 21:32:07 -0400 Subject: [PATCH 01/17] Document service account choices for AD-aware hooks Add a Service account section to PLAN.md and README.md covering LocalSystem, domain user, and gMSA install paths so users running AD PowerShell scripts know which identity to pick. Drop the stale "outbound webhook delivery" out-of-scope bullet now that callbacks are in v1. Co-Authored-By: Claude Opus 4.7 (1M context) --- PLAN.md | 29 +++++++++++++++++++++++++++++ README.md | 27 +++++++++++++++++++++++++-- 2 files changed, 54 insertions(+), 2 deletions(-) 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. -- 2.52.0 From 8ecfe84540bb045f0f448abd74e2d62551935276 Mon Sep 17 00:00:00 2001 From: Justin Paul Date: Thu, 7 May 2026 22:04:52 -0400 Subject: [PATCH 02/17] Initial WebhookServer implementation Add the .NET 8 solution scaffolded against PLAN.md. Three projects share WebhookServer.Core (models, auth, execution, storage, IPC, callbacks) and WebhookServer.Service hosts an embedded Kestrel listener plus the named-pipe admin server. WebhookServer.Gui is a thin MVVM client over the pipe. Includes 25 unit tests covering HMAC verification, bearer auth, IP allowlist parsing, arg-template rendering, DPAPI round-trip, and the encrypt-on-save config store. Install/uninstall PowerShell scripts default to LocalSystem and accept a domain user or gMSA via -ServiceAccount. Co-Authored-By: Claude Opus 4.7 (1M context) --- WebhookServer.sln | 50 +++ scripts/install-service.ps1 | 77 +++++ scripts/uninstall-service.ps1 | 38 +++ src/WebhookServer.Core/Auth/AuthResult.cs | 7 + src/WebhookServer.Core/Auth/BearerVerifier.cs | 32 ++ src/WebhookServer.Core/Auth/HmacVerifier.cs | 76 +++++ src/WebhookServer.Core/Auth/IpAllowList.cs | 87 +++++ .../Callbacks/CallbackDispatcher.cs | 219 +++++++++++++ .../Callbacks/CallbackEnvelope.cs | 15 + .../Callbacks/CallbackPayload.cs | 22 ++ .../Execution/ArgTemplateRenderer.cs | 116 +++++++ .../Execution/ConcurrencyGate.cs | 37 +++ .../Execution/ExecutionContext.cs | 18 ++ .../Execution/ExecutionResult.cs | 18 ++ src/WebhookServer.Core/Execution/IExecutor.cs | 8 + .../Execution/ProcessExecutor.cs | 234 ++++++++++++++ src/WebhookServer.Core/Ipc/AdminProtocol.cs | 91 ++++++ src/WebhookServer.Core/Ipc/PipeFraming.cs | 29 ++ .../Ipc/PipeSecurityFactory.cs | 33 ++ .../Models/BearerOptions.cs | 6 + .../Models/CallbackConfig.cs | 16 + .../Models/DataPassingOptions.cs | 14 + .../Models/EndpointConfig.cs | 45 +++ src/WebhookServer.Core/Models/Enums.cs | 55 ++++ src/WebhookServer.Core/Models/HmacOptions.cs | 10 + src/WebhookServer.Core/Models/HttpsBinding.cs | 17 + .../Models/ProtectedString.cs | 29 ++ src/WebhookServer.Core/Models/ServerConfig.cs | 17 + src/WebhookServer.Core/Storage/ConfigJson.cs | 27 ++ src/WebhookServer.Core/Storage/ConfigStore.cs | 118 +++++++ src/WebhookServer.Core/Storage/DpapiSecret.cs | 30 ++ .../WebhookServer.Core.csproj | 15 + src/WebhookServer.Gui/App.xaml | 11 + src/WebhookServer.Gui/App.xaml.cs | 13 + src/WebhookServer.Gui/AssemblyInfo.cs | 10 + .../Converters/Converters.cs | 35 +++ src/WebhookServer.Gui/MainWindow.xaml | 87 +++++ src/WebhookServer.Gui/MainWindow.xaml.cs | 16 + .../Services/AdminPipeClient.cs | 89 ++++++ .../ViewModels/EndpointEditorViewModel.cs | 76 +++++ .../ViewModels/MainViewModel.cs | 189 +++++++++++ .../ViewModels/ServerSettingsViewModel.cs | 60 ++++ .../Views/EndpointEditor.xaml | 155 +++++++++ .../Views/EndpointEditor.xaml.cs | 33 ++ .../Views/ServerSettings.xaml | 74 +++++ .../Views/ServerSettings.xaml.cs | 33 ++ .../WebhookServer.Gui.csproj | 19 ++ src/WebhookServer.Service/AdminPipeServer.cs | 297 ++++++++++++++++++ .../CallbackBackgroundService.cs | 14 + src/WebhookServer.Service/Program.cs | 116 +++++++ src/WebhookServer.Service/ServicePaths.cs | 16 + src/WebhookServer.Service/ServiceState.cs | 115 +++++++ src/WebhookServer.Service/WebhookRouter.cs | 285 +++++++++++++++++ .../WebhookServer.Service.csproj | 21 ++ src/WebhookServer.Service/appsettings.json | 8 + .../ArgTemplateRendererTests.cs | 74 +++++ .../BearerVerifierTests.cs | 27 ++ .../ConfigStoreTests.cs | 52 +++ .../DpapiSecretTests.cs | 27 ++ .../HmacVerifierTests.cs | 79 +++++ .../IpAllowListTests.cs | 57 ++++ .../WebhookServer.Core.Tests.csproj | 27 ++ 62 files changed, 3721 insertions(+) create mode 100644 WebhookServer.sln create mode 100644 scripts/install-service.ps1 create mode 100644 scripts/uninstall-service.ps1 create mode 100644 src/WebhookServer.Core/Auth/AuthResult.cs create mode 100644 src/WebhookServer.Core/Auth/BearerVerifier.cs create mode 100644 src/WebhookServer.Core/Auth/HmacVerifier.cs create mode 100644 src/WebhookServer.Core/Auth/IpAllowList.cs create mode 100644 src/WebhookServer.Core/Callbacks/CallbackDispatcher.cs create mode 100644 src/WebhookServer.Core/Callbacks/CallbackEnvelope.cs create mode 100644 src/WebhookServer.Core/Callbacks/CallbackPayload.cs create mode 100644 src/WebhookServer.Core/Execution/ArgTemplateRenderer.cs create mode 100644 src/WebhookServer.Core/Execution/ConcurrencyGate.cs create mode 100644 src/WebhookServer.Core/Execution/ExecutionContext.cs create mode 100644 src/WebhookServer.Core/Execution/ExecutionResult.cs create mode 100644 src/WebhookServer.Core/Execution/IExecutor.cs create mode 100644 src/WebhookServer.Core/Execution/ProcessExecutor.cs create mode 100644 src/WebhookServer.Core/Ipc/AdminProtocol.cs create mode 100644 src/WebhookServer.Core/Ipc/PipeFraming.cs create mode 100644 src/WebhookServer.Core/Ipc/PipeSecurityFactory.cs create mode 100644 src/WebhookServer.Core/Models/BearerOptions.cs create mode 100644 src/WebhookServer.Core/Models/CallbackConfig.cs create mode 100644 src/WebhookServer.Core/Models/DataPassingOptions.cs create mode 100644 src/WebhookServer.Core/Models/EndpointConfig.cs create mode 100644 src/WebhookServer.Core/Models/Enums.cs create mode 100644 src/WebhookServer.Core/Models/HmacOptions.cs create mode 100644 src/WebhookServer.Core/Models/HttpsBinding.cs create mode 100644 src/WebhookServer.Core/Models/ProtectedString.cs create mode 100644 src/WebhookServer.Core/Models/ServerConfig.cs create mode 100644 src/WebhookServer.Core/Storage/ConfigJson.cs create mode 100644 src/WebhookServer.Core/Storage/ConfigStore.cs create mode 100644 src/WebhookServer.Core/Storage/DpapiSecret.cs create mode 100644 src/WebhookServer.Core/WebhookServer.Core.csproj create mode 100644 src/WebhookServer.Gui/App.xaml create mode 100644 src/WebhookServer.Gui/App.xaml.cs create mode 100644 src/WebhookServer.Gui/AssemblyInfo.cs create mode 100644 src/WebhookServer.Gui/Converters/Converters.cs create mode 100644 src/WebhookServer.Gui/MainWindow.xaml create mode 100644 src/WebhookServer.Gui/MainWindow.xaml.cs create mode 100644 src/WebhookServer.Gui/Services/AdminPipeClient.cs create mode 100644 src/WebhookServer.Gui/ViewModels/EndpointEditorViewModel.cs create mode 100644 src/WebhookServer.Gui/ViewModels/MainViewModel.cs create mode 100644 src/WebhookServer.Gui/ViewModels/ServerSettingsViewModel.cs create mode 100644 src/WebhookServer.Gui/Views/EndpointEditor.xaml create mode 100644 src/WebhookServer.Gui/Views/EndpointEditor.xaml.cs create mode 100644 src/WebhookServer.Gui/Views/ServerSettings.xaml create mode 100644 src/WebhookServer.Gui/Views/ServerSettings.xaml.cs create mode 100644 src/WebhookServer.Gui/WebhookServer.Gui.csproj create mode 100644 src/WebhookServer.Service/AdminPipeServer.cs create mode 100644 src/WebhookServer.Service/CallbackBackgroundService.cs create mode 100644 src/WebhookServer.Service/Program.cs create mode 100644 src/WebhookServer.Service/ServicePaths.cs create mode 100644 src/WebhookServer.Service/ServiceState.cs create mode 100644 src/WebhookServer.Service/WebhookRouter.cs create mode 100644 src/WebhookServer.Service/WebhookServer.Service.csproj create mode 100644 src/WebhookServer.Service/appsettings.json create mode 100644 tests/WebhookServer.Core.Tests/ArgTemplateRendererTests.cs create mode 100644 tests/WebhookServer.Core.Tests/BearerVerifierTests.cs create mode 100644 tests/WebhookServer.Core.Tests/ConfigStoreTests.cs create mode 100644 tests/WebhookServer.Core.Tests/DpapiSecretTests.cs create mode 100644 tests/WebhookServer.Core.Tests/HmacVerifierTests.cs create mode 100644 tests/WebhookServer.Core.Tests/IpAllowListTests.cs create mode 100644 tests/WebhookServer.Core.Tests/WebhookServer.Core.Tests.csproj 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/scripts/install-service.ps1 b/scripts/install-service.ps1 new file mode 100644 index 0000000..a6ef3af --- /dev/null +++ b/scripts/install-service.ps1 @@ -0,0 +1,77 @@ +<# +.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" +} + +# Build sc.exe argv. Note: sc.exe is fussy about spaces — keep "key= value" format. +$obj = $ServiceAccount +$existing = sc.exe query $ServiceName 2>$null + +if ($existing) { + Write-Host "Service '$ServiceName' already exists; updating binPath and account." + sc.exe config $ServiceName binPath= "`"$BinaryPath`"" obj= $obj $(if ($Password) { "password= $Password" }) | Out-Null +} else { + $args = @( + 'create', $ServiceName, + "binPath=", "`"$BinaryPath`"", + "DisplayName=", "`"$DisplayName`"", + "start=", "auto", + "obj=", $obj + ) + if ($Password) { $args += @('password=', $Password) } + sc.exe @args | Out-Null +} + +# 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 | Out-Null + +Write-Host "Starting service '$ServiceName'..." +sc.exe start $ServiceName | Out-Null + +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/ProcessExecutor.cs b/src/WebhookServer.Core/Execution/ProcessExecutor.cs new file mode 100644 index 0000000..0a84e9f --- /dev/null +++ b/src/WebhookServer.Core/Execution/ProcessExecutor.cs @@ -0,0 +1,234 @@ +using System.Diagnostics; +using System.Text; +using WebhookServer.Core.Models; + +namespace WebhookServer.Core.Execution; + +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 psi = BuildStartInfo(endpoint, ctx); + + 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}"); + } + + // stdin + 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 { /* swallow */ } + } + } + else + { + try { process.StandardInput.Close(); } catch { /* swallow */ } + } + + // Capture stdout/stderr in parallel, with per-stream cap. + 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 { /* swallow */ } + try { await process.WaitForExitAsync(CancellationToken.None).ConfigureAwait(false); } catch { /* swallow */ } + } + + 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, + }; + } + + private static ProcessStartInfo 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); + } + + if (endpoint.DataPassing.EnvVars) + { + foreach (var (k, v) in ctx.Headers) + psi.Environment[$"WEBHOOK_HEADER_{Sanitize(k)}"] = v; + foreach (var (k, v) in ctx.Query) + psi.Environment[$"WEBHOOK_QUERY_{Sanitize(k)}"] = v; + } + + psi.Environment["WEBHOOK_RUN_ID"] = ctx.RunId; + psi.Environment["WEBHOOK_SLUG"] = ctx.Slug; + + return psi; + } + + 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"); + psi.ArgumentList.Add(endpoint.InlineCommand ?? ""); + } + } + + private static string ResolveCmdInvocation(EndpointConfig endpoint) + { + if (!string.IsNullOrEmpty(endpoint.ScriptPath)) + return endpoint.ScriptPath!; + return endpoint.InlineCommand ?? ""; + } + + 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; + + // Cheap byte estimate (ASCII-ish); good enough as a guard rail. + 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; + } + } + // Else keep draining without storing to keep the pipe from blocking. + } + + 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..1e0397d --- /dev/null +++ b/src/WebhookServer.Core/Ipc/AdminProtocol.cs @@ -0,0 +1,91 @@ +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 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 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..87c0f7b --- /dev/null +++ b/src/WebhookServer.Core/Models/EndpointConfig.cs @@ -0,0 +1,45 @@ +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; } +} diff --git a/src/WebhookServer.Core/Models/Enums.cs b/src/WebhookServer.Core/Models/Enums.cs new file mode 100644 index 0000000..3fd094b --- /dev/null +++ b/src/WebhookServer.Core/Models/Enums.cs @@ -0,0 +1,55 @@ +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, +} 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/ServerConfig.cs b/src/WebhookServer.Core/Models/ServerConfig.cs new file mode 100644 index 0000000..52620fb --- /dev/null +++ b/src/WebhookServer.Core/Models/ServerConfig.cs @@ -0,0 +1,17 @@ +namespace WebhookServer.Core.Models; + +public sealed class ServerConfig +{ + public int HttpPort { get; set; } = 8080; + public HttpsBinding? HttpsBinding { 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..e83aaf6 --- /dev/null +++ b/src/WebhookServer.Core/Storage/ConfigStore.cs @@ -0,0 +1,118 @@ +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); + + 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); + } + + public static void ClearPlaintexts(ServerConfig config) + { + foreach (var ep in config.Endpoints) + { + ClearOne(ep.Bearer?.Secret); + ClearOne(ep.Hmac?.Secret); + 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); + 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); + 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..5693866 --- /dev/null +++ b/src/WebhookServer.Gui/App.xaml @@ -0,0 +1,11 @@ + + + + + + + diff --git a/src/WebhookServer.Gui/App.xaml.cs b/src/WebhookServer.Gui/App.xaml.cs new file mode 100644 index 0000000..9e28109 --- /dev/null +++ b/src/WebhookServer.Gui/App.xaml.cs @@ -0,0 +1,13 @@ +using System.Configuration; +using System.Data; +using System.Windows; + +namespace WebhookServer.Gui; + +/// +/// Interaction logic for App.xaml +/// +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..bc3f6ad --- /dev/null +++ b/src/WebhookServer.Gui/Converters/Converters.cs @@ -0,0 +1,35 @@ +using System.Globalization; +using System.Windows.Data; +using System.Windows.Media; + +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 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/MainWindow.xaml b/src/WebhookServer.Gui/MainWindow.xaml new file mode 100644 index 0000000..9925e9b --- /dev/null +++ b/src/WebhookServer.Gui/MainWindow.xaml @@ -0,0 +1,87 @@ + + + + + + + + + + + + +