From 8ecfe84540bb045f0f448abd74e2d62551935276 Mon Sep 17 00:00:00 2001 From: Justin Paul Date: Thu, 7 May 2026 22:04:52 -0400 Subject: [PATCH] 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 @@ + + + + + + + + + + + + +