10 Commits

Author SHA1 Message Date
justin a45d994c18 Phase 1: versioning, menu bar, About dialog, right-click context menu
- Directory.Build.props sets Version=0.1.0 (semver pre-1.0 = beta) plus
  Authors / Product / RepositoryUrl, picked up by all three projects.
- MainWindow gets a real menu bar (File / Server / Help) replacing the
  old toolbar. File: New endpoint / Import / Export / Backups (last
  three are stubs for the next phase) / Exit. Server: Settings /
  Restart service. Help: About.
- Drop the Refresh button - the 3 s polling loop covers it.
- DataGridRow gets a right-click context menu: Edit / Copy URL /
  toggle Enabled / Delete.
- New About dialog reads AssemblyInformationalVersion at runtime and
  links jpaul.me + the GitHub repo via clickable hyperlinks.
- Ctrl+N input binding for new-endpoint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 09:39:18 -04:00
justin 28479272d5 Configurable bind addresses + display host in Server Settings
ServerConfig grows two fields:
- BindAddresses: list of IPs Kestrel binds to (empty = all interfaces,
  current behavior). Listening only on a subset is useful when the host
  has multiple NICs and the webhook should not be reachable on all of
  them.
- DisplayHost: the hostname/IP the GUI splices into the URL column and
  Copy URL button. Cosmetic; doesn't affect what the server accepts.

Server Settings dialog gains a "Network" section: a checkbox for "all
interfaces" plus per-NIC checkboxes auto-detected via NetworkInterface.
GetAllNetworkInterfaces, and an editable ComboBox for the display host
pre-populated with detected IPs and the machine name.

Listener restart fires on BindAddresses change but not on DisplayHost
change (cosmetic).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 09:27:18 -04:00
justin 4ef8d20578 Skip lpDesktop=winsta0\default for SpecificUser launches
Setting lpDesktop on STARTUPINFO forces the child to open that desktop;
the LogonUser-derived token in SpecificUser mode usually cannot, since
winsta0\default's DACL only grants the currently-logged-in user. The
result was STATUS_DLL_INIT_FAILED (exit 0xC0000142) with empty stdio.

Only InteractiveUser mode needs the explicit interactive desktop -
that whole point of the mode is to land in the user's session. For
SpecificUser, leaving lpDesktop null lets the child inherit our
service desktop, which works for headless batch tasks (AD reads, file
ops, etc.).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 09:18:59 -04:00
justin 8b855ec9b9 Route SpecificUser through LogonUser+CreateProcessAsUser, not psi.UserName
CreateProcessWithLogonW (which ProcessStartInfo.UserName/Password uses
under the hood) refuses to run when the caller is LocalSystem - which
is exactly the scenario every hook hits, since the service runs as
SYSTEM by default. The hook just got "Access is denied" with no useful
context.

Switch SpecificUser to the same LogonUser + DuplicateTokenEx +
CreateProcessAsUser path that InteractiveUser already uses. The
launcher tries LOGON32_LOGON_INTERACTIVE first, falling back to
LOGON32_LOGON_BATCH for accounts without interactive-logon rights
(typical for service-only users). Domain "." is normalized to the
machine name so ".\justin" works.

The launcher's two public entry points - LaunchAsActiveConsoleUser
and LaunchAsSpecificUser - share the same LaunchWithToken core, so
stdio capture and environment-block construction stay identical.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 09:17:07 -04:00
justin 1e48b8185b Log execution outcomes (success / failure / timeout / launch error)
Hook runs were silently dropping their result into the void after
returning the HTTP response. For sync runs the body went to the
caller but nothing was logged; for async runs the result vanished
unless a callback was configured. That made debugging RunAs
failures (logon errors, missing executables) effectively
impossible since the service log only showed the 202.

Now every run emits one log line at INF (success) or WRN
(non-zero exit / timeout / launch error) with runId, slug, exit
code, duration, and truncated stdout/stderr.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 09:12:35 -04:00
justin 24d8701b65 Per-endpoint RunAs: Service / InteractiveUser / SpecificUser
Native per-endpoint identity instead of the schtasks bridge:
- Service (default) keeps the existing path - hooks inherit the service
  account (SYSTEM by default, or whatever you installed under).
- SpecificUser binds ProcessStartInfo.UserName / Password / Domain so
  the hook runs in a batch logon session as the named account. Useful
  for AD-write hooks that should NOT run as SYSTEM.
- InteractiveUser uses WTSQueryUserToken(WTSGetActiveConsoleSessionId)
  + DuplicateTokenEx + CreateProcessAsUser to drop the child into the
  logged-in user's session with their environment block. This is the
  real fix for "calc.exe should pop up on my desktop" - no Task
  Scheduler bridge required. Stdio is captured via inheritable
  anonymous pipes so the hook still returns stdout/stderr to the
  caller normally.

Implementation:
- New RunAsMode enum + RunAsConfig model on EndpointConfig
- ConfigStore round-trips RunAs.Password through DPAPI alongside
  bearer/HMAC/PFX secrets
- AdminPipeServer's secret-merge logic preserves the encrypted blob
  when the GUI saves an endpoint without re-typing the password
- New WebhookServer.Core.Execution.Native namespace with NativeMethods
  (P/Invoke) and InteractiveProcessLauncher (token-based launcher)
- ProcessExecutor branches on RunAs.Mode; the Service/SpecificUser
  paths share .NET's Process; InteractiveUser uses the launcher
- GUI editor gets a "Run as" section: dropdown + conditional
  username/password/load-profile fields under SpecificUser

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 09:08:08 -04:00
justin 27e5264714 Replace em-dashes in PowerShell scripts with ASCII hyphens
PowerShell 5.1 reads .ps1 files as the local ANSI codepage by default,
so non-ASCII characters get garbled. An em-dash inside a string literal
broke install-service.ps1 with a parser error. Sticking to ASCII in
script source avoids the entire class of issue.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 08:53:04 -04:00
justin ab53c96928 Fix install-service.ps1 service-existence check + propagate sc.exe failures
sc.exe query writes "The specified service does not exist" to stdout
when the service is missing, so checking truthy on its output was
useless — it always took the update branch and silently failed when
piped to Out-Null. Switch to Get-Service which returns $null cleanly,
and stop swallowing sc.exe output so missing-service / permission /
account errors actually surface as PowerShell errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 08:51:56 -04:00
justin 87bcb6807f GUI UX, secret visibility, browser-friendly hooks, deploy script
GUI:
- URL column in endpoint grid + Copy URL toolbar button so the full
  http://host:port/hook/<slug> is one click away
- Double-click a row to open the edit dialog
- Bearer/HMAC sections in the editor hide when the auth mode doesn't
  use them, and reappear with previously-entered values when switched
  back
- Log panel auto-scroll checkbox (default on) plus 3s polling so log
  entries stream in without manual refresh
- Secret fields are now plain text with a Copy button. Anyone who can
  open the admin-pipe-ACL'd GUI is already SYSTEM-equivalent on the
  host, so masking the value just made recovery harder. PFX password
  in Server Settings gets the same treatment.

Service:
- Admin pipe ops log info-level lines on every mutation
  (create/update/delete/enable/disable/update-config/bind-https) so
  GUI activity is visible in the Serilog file
- /hook/{slug} accepts GET as well as POST so a browser smoke-test
  works without curl
- /favicon.ico returns 204 so browser hits don't pollute logs with 404s
- AdminPipeServer no longer strips plaintext secrets when sending
  config to the GUI; the pipe ACL already restricts to SYSTEM/Admins

Scripts:
- New deploy.ps1: stops + republishes + copies binaries to
  C:\Program Files\WebhookServer + (re)installs the Windows Service
- install-service.ps1 now uses sc.exe argv splatting consistently for
  both create and config paths

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 08:47:11 -04:00
justin 882d5332b4 Add dev-launch.ps1 to start service + GUI for visual smoke testing
Stands up an isolated data root, seeds a single sample endpoint, opens
the service in its own window, then launches the GUI with the matching
WEBHOOKSERVER_DATA environment variable. Refuses to run from a
non-elevated shell since the admin pipe is ACL'd to SYSTEM and
Administrators only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 08:17:05 -04:00
30 changed files with 1497 additions and 100 deletions
+14
View File
@@ -0,0 +1,14 @@
<Project>
<PropertyGroup>
<Version>0.1.0</Version>
<Authors>Justin Paul</Authors>
<Company>Justin Paul</Company>
<Product>Webhook Server</Product>
<Copyright>Copyright (c) Justin Paul</Copyright>
<PackageProjectUrl>https://jpaul.me</PackageProjectUrl>
<RepositoryUrl>https://github.com/recklessop/webhook-server</RepositoryUrl>
<RepositoryType>git</RepositoryType>
</PropertyGroup>
</Project>
+102
View File
@@ -0,0 +1,102 @@
<#
.SYNOPSIS
Builds, publishes, copies, installs, and starts WebhookServer as a Windows Service
running under LocalSystem.
.DESCRIPTION
Idempotent - safe to re-run after code changes. Stops the service first so binaries
aren't locked, copies the latest published output to InstallRoot, then re-creates or
re-configures the service and starts it.
Must be run from an elevated PowerShell.
.PARAMETER InstallRoot
Where the binaries get copied. Defaults to "C:\Program Files\WebhookServer".
.PARAMETER ServiceAccount
Service identity. Defaults to LocalSystem. For AD-aware hooks pass a domain user
or gMSA - see the Service account section in README.md.
.PARAMETER SkipBuild
Skip the dotnet publish step (use the existing publish\ output as-is).
.EXAMPLE
# First-time install (and after any code change)
.\deploy.ps1
.EXAMPLE
# Run service under a gMSA
.\deploy.ps1 -ServiceAccount 'CONTOSO\svc-webhookserver$'
#>
[CmdletBinding()]
param(
[string]$InstallRoot = 'C:\Program Files\WebhookServer',
[string]$ServiceName = 'WebhookServer',
[string]$ServiceAccount = 'LocalSystem',
[string]$Password,
[switch]$SkipBuild
)
$ErrorActionPreference = 'Stop'
$principal = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent())
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
throw 'deploy.ps1 must be run from an elevated PowerShell.'
}
$repoRoot = Split-Path -Parent $PSScriptRoot
$publishSvc = Join-Path $repoRoot 'publish\service'
$publishGui = Join-Path $repoRoot 'publish\gui'
# 1. Stop the service if it's already installed so its binaries aren't locked.
$svc = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
if ($svc -and $svc.Status -ne 'Stopped') {
Write-Host "Stopping existing service '$ServiceName'..."
Stop-Service -Name $ServiceName -Force
$svc.WaitForStatus('Stopped', '00:00:30')
}
# Belt-and-braces: kill any orphan dev-launch processes still holding the binaries.
Get-Process -Name 'WebhookServer.Service','WebhookServer.Gui' -ErrorAction SilentlyContinue |
ForEach-Object { try { $_ | Stop-Process -Force } catch { } }
# 2. Publish (unless told to skip).
if (-not $SkipBuild) {
Write-Host 'Publishing service + GUI...'
& dotnet publish (Join-Path $repoRoot 'src\WebhookServer.Service\WebhookServer.Service.csproj') `
-c Release -r win-x64 --self-contained false -o $publishSvc | Out-Host
if ($LASTEXITCODE -ne 0) { throw 'service publish failed' }
& dotnet publish (Join-Path $repoRoot 'src\WebhookServer.Gui\WebhookServer.Gui.csproj') `
-c Release -r win-x64 --self-contained false -o $publishGui | Out-Host
if ($LASTEXITCODE -ne 0) { throw 'GUI publish failed' }
}
# 3. Copy binaries into InstallRoot.
Write-Host "Copying binaries to $InstallRoot..."
New-Item -ItemType Directory -Path $InstallRoot -Force | Out-Null
Copy-Item -Path (Join-Path $publishSvc '*') -Destination $InstallRoot -Recurse -Force
Copy-Item -Path (Join-Path $publishGui '*') -Destination $InstallRoot -Recurse -Force
$serviceExe = Join-Path $InstallRoot 'WebhookServer.Service.exe'
$guiExe = Join-Path $InstallRoot 'WebhookServer.Gui.exe'
# 4. Create or update the Windows Service via install-service.ps1.
$installArgs = @{
BinaryPath = $serviceExe
ServiceName = $ServiceName
ServiceAccount = $ServiceAccount
}
if ($PSBoundParameters.ContainsKey('Password')) { $installArgs.Password = $Password }
& (Join-Path $PSScriptRoot 'install-service.ps1') @installArgs
# 5. Show how to launch the GUI.
Write-Host ''
Write-Host '=== Deployed ===' -ForegroundColor Green
Write-Host " Service exe : $serviceExe"
Write-Host " GUI exe : $guiExe"
Write-Host " Config : $env:ProgramData\WebhookServer\config.json"
Write-Host " Logs : $env:ProgramData\WebhookServer\logs"
Write-Host ''
Write-Host 'Launch the GUI (must stay elevated to talk to the admin pipe):'
Write-Host " Start-Process -FilePath '$guiExe' -Verb RunAs"
+83
View File
@@ -0,0 +1,83 @@
<#
.SYNOPSIS
Dev launcher: starts the service in one window and the GUI in another, both
pointing at an isolated data root so production %ProgramData% is not touched.
.DESCRIPTION
MUST be run from an elevated PowerShell - the admin pipe is ACL'd to SYSTEM
and the Administrators group, and a non-elevated process cannot connect.
#>
[CmdletBinding()]
param(
[string]$DataRoot = (Join-Path $env:TEMP 'webhook-dev'),
[int]$HttpPort = 18080
)
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$servicePath = Join-Path $root 'publish\service\WebhookServer.Service.exe'
$guiPath = Join-Path $root 'publish\gui\WebhookServer.Gui.exe'
if (-not (Test-Path $servicePath)) { throw "Service not built. Run: dotnet publish src/WebhookServer.Service -c Release -o publish/service" }
if (-not (Test-Path $guiPath)) { throw "GUI not built. Run: dotnet publish src/WebhookServer.Gui -c Release -o publish/gui" }
# Verify the current shell is elevated.
$currentPrincipal = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent())
if (-not $currentPrincipal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
throw 'This script must be run from an elevated PowerShell so the GUI can connect to the SYSTEM/Admins-only admin pipe.'
}
New-Item -ItemType Directory -Path $DataRoot -Force | Out-Null
New-Item -ItemType Directory -Path (Join-Path $DataRoot 'logs') -Force | Out-Null
$cfgPath = Join-Path $DataRoot 'config.json'
if (-not (Test-Path $cfgPath)) {
$cfg = @"
{
"httpPort": $HttpPort,
"trustedProxies": [],
"logRetentionDays": 7,
"endpoints": [
{
"id": "11111111-1111-1111-1111-111111111111",
"slug": "ping",
"description": "Trivial sync hook",
"enabled": true,
"allowedClients": [],
"authMode": "none",
"executorType": "windowsPowerShell",
"inlineCommand": "Write-Output 'pong'",
"executableArgs": [],
"dataPassing": { "stdinJson": false, "envVars": false, "argTemplate": false },
"responseMode": "sync",
"timeoutSeconds": 30,
"failOnNonZeroExit": true,
"serialize": false
}
]
}
"@
Set-Content -Path $cfgPath -Value $cfg -Encoding utf8
}
Write-Host "Data root : $DataRoot"
Write-Host "Config : $cfgPath"
Write-Host "Service exe: $servicePath"
Write-Host "GUI exe : $guiPath"
Write-Host ""
$serviceArgs = @(
'-NoExit', '-NoProfile', '-Command',
"`$env:WEBHOOKSERVER_DATA = '$DataRoot'; & '$servicePath'"
)
Start-Process powershell -ArgumentList $serviceArgs -WindowStyle Normal
Start-Sleep -Seconds 2
# GUI inherits this shell's environment automatically.
$env:WEBHOOKSERVER_DATA = $DataRoot
Start-Process -FilePath $guiPath
Write-Host "Service window opened; GUI launched."
Write-Host "Hit http://localhost:$HttpPort/healthz to confirm Kestrel is up."
Write-Host "Logs: $(Join-Path $DataRoot 'logs')"
+29 -16
View File
@@ -14,8 +14,8 @@
.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.
(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,
@@ -48,30 +48,43 @@ 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.
# sc.exe argv format: "key= value" - space AFTER equals, none before.
$obj = $ServiceAccount
$existing = sc.exe query $ServiceName 2>$null
# Get-Service returns $null when the service doesn't exist; sc.exe query is unreliable
# because it writes a FAILED line to stdout that makes truthy checks pass.
$existing = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
if ($existing) {
Write-Host "Service '$ServiceName' already exists; updating binPath and account."
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
$configArgs = @(
'config', $ServiceName,
'binPath=', "`"$BinaryPath`"",
'obj=', $obj
)
if ($Password) { $args += @('password=', $Password) }
sc.exe @args | Out-Null
if ($Password) { $configArgs += @('password=', $Password) }
sc.exe @configArgs
if ($LASTEXITCODE -ne 0) { throw "sc.exe config failed with exit code $LASTEXITCODE" }
} else {
Write-Host "Creating service '$ServiceName'..."
$createArgs = @(
'create', $ServiceName,
'binPath=', "`"$BinaryPath`"",
'DisplayName=', "`"$DisplayName`"",
'start=', 'auto',
'obj=', $obj
)
if ($Password) { $createArgs += @('password=', $Password) }
sc.exe @createArgs
if ($LASTEXITCODE -ne 0) { throw "sc.exe create failed with exit code $LASTEXITCODE" }
}
# Configure failure recovery: restart the service on first/second failure, reset count after a day.
sc.exe failure $ServiceName reset= 86400 actions= restart/5000/restart/5000/restart/5000 | Out-Null
sc.exe failure $ServiceName reset= 86400 actions= restart/5000/restart/5000/restart/5000
if ($LASTEXITCODE -ne 0) { Write-Warning "sc.exe failure returned $LASTEXITCODE (recovery actions may not be set)" }
Write-Host "Starting service '$ServiceName'..."
sc.exe start $ServiceName | Out-Null
sc.exe start $ServiceName
if ($LASTEXITCODE -ne 0) { throw "sc.exe start failed with exit code $LASTEXITCODE - check Event Viewer (Windows Logs > Application) for details" }
Start-Sleep -Seconds 1
sc.exe query $ServiceName
@@ -0,0 +1,332 @@
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using System.Text;
using Microsoft.Win32.SafeHandles;
using static WebhookServer.Core.Execution.Native.NativeMethods;
namespace WebhookServer.Core.Execution.Native;
/// <summary>
/// Launches a child process inside the active console session under whoever is
/// logged in at the keyboard. Required when running as SYSTEM (the service account)
/// because <c>Process.Start</c> would land the child in Session 0 where it can't
/// show UI on the user's desktop.
///
/// Caller must already be SYSTEM (the service runs as SYSTEM by default) — only
/// SYSTEM can call <c>WTSQueryUserToken</c>.
/// </summary>
[SupportedOSPlatform("windows")]
internal static class InteractiveProcessLauncher
{
public sealed class LaunchOptions
{
public required string FileName { get; init; }
public required IReadOnlyList<string> Arguments { get; init; }
public string? WorkingDirectory { get; init; }
public IReadOnlyDictionary<string, string>? ExtraEnvVars { get; init; }
public byte[]? StdinBytes { get; init; }
}
public sealed class LaunchResult : IDisposable
{
public required IntPtr ProcessHandle { get; init; }
public required uint ProcessId { get; init; }
public required StreamReader Stdout { get; init; }
public required StreamReader Stderr { get; init; }
public void Dispose()
{
try { Stdout.Dispose(); } catch { }
try { Stderr.Dispose(); } catch { }
if (ProcessHandle != IntPtr.Zero)
try { CloseHandle(ProcessHandle); } catch { }
}
}
/// <summary>
/// Launch into the session of whoever is logged in at the keyboard. Lets hooks
/// pop UI on the user's desktop. Caller must be SYSTEM (only SYSTEM can call
/// WTSQueryUserToken).
/// </summary>
public static LaunchResult LaunchAsActiveConsoleUser(LaunchOptions opts)
{
var sessionId = WTSGetActiveConsoleSessionId();
if (sessionId == INVALID_SESSION_ID)
throw new InvalidOperationException("No active console session - is anyone logged in at the keyboard?");
if (!WTSQueryUserToken(sessionId, out var userToken))
throw LastError("WTSQueryUserToken (must run as SYSTEM)");
try { return LaunchWithToken(userToken, opts, useInteractiveDesktop: true); }
finally { CloseHandle(userToken); }
}
/// <summary>
/// Launch under a username/password by calling LogonUser to obtain a token.
/// Used instead of psi.UserName/Password because CreateProcessWithLogonW (what
/// .NET uses under the hood) refuses to run when the caller is SYSTEM.
/// Tries interactive logon first, then batch.
/// </summary>
public static LaunchResult LaunchAsSpecificUser(string username, string password, string? domain, LaunchOptions opts)
{
var resolvedDomain = NormalizeDomain(domain);
IntPtr token;
if (!LogonUser(username, resolvedDomain, password, LOGON32_LOGON_INTERACTIVE, LOGON32_PROVIDER_DEFAULT, out token))
{
if (!LogonUser(username, resolvedDomain, password, LOGON32_LOGON_BATCH, LOGON32_PROVIDER_DEFAULT, out token))
{
var who = string.IsNullOrEmpty(domain) ? username : $"{domain}\\{username}";
throw LastError($"LogonUser ({who})");
}
}
try { return LaunchWithToken(token, opts, useInteractiveDesktop: false); }
finally { CloseHandle(token); }
}
private static string? NormalizeDomain(string? domain)
{
if (string.IsNullOrEmpty(domain)) return null;
// "." is a common shorthand for "this machine"; LogonUser wants the actual
// machine name or null for local accounts.
if (domain == ".") return Environment.MachineName;
return domain;
}
private static LaunchResult LaunchWithToken(IntPtr sourceToken, LaunchOptions opts, bool useInteractiveDesktop)
{
IntPtr primaryToken = IntPtr.Zero;
IntPtr envBlock = IntPtr.Zero;
IntPtr stdoutRead = IntPtr.Zero, stdoutWrite = IntPtr.Zero;
IntPtr stderrRead = IntPtr.Zero, stderrWrite = IntPtr.Zero;
IntPtr stdinRead = IntPtr.Zero, stdinWrite = IntPtr.Zero;
var pi = new PROCESS_INFORMATION();
bool succeeded = false;
try
{
if (!DuplicateTokenEx(sourceToken, (uint)MAXIMUM_ALLOWED, IntPtr.Zero,
SECURITY_IMPERSONATION_LEVEL.SecurityImpersonation,
TOKEN_TYPE.TokenPrimary, out primaryToken))
throw LastError("DuplicateTokenEx");
if (!CreateEnvironmentBlock(out envBlock, primaryToken, false))
throw LastError("CreateEnvironmentBlock");
if (opts.ExtraEnvVars is { Count: > 0 })
envBlock = AppendEnvVars(envBlock, opts.ExtraEnvVars);
CreateInheritablePipe(out stdoutRead, out stdoutWrite, parentReads: true);
CreateInheritablePipe(out stderrRead, out stderrWrite, parentReads: true);
CreateInheritablePipe(out stdinRead, out stdinWrite, parentReads: false);
var si = new STARTUPINFO
{
cb = Marshal.SizeOf<STARTUPINFO>(),
dwFlags = STARTF_USESTDHANDLES,
hStdInput = stdinRead,
hStdOutput = stdoutWrite,
hStdError = stderrWrite,
// For InteractiveUser we explicitly target the logged-in user's desktop.
// For SpecificUser the LogonUser-derived token typically can't open that
// DACL; leave lpDesktop null and let the new process inherit ours.
lpDesktop = useInteractiveDesktop ? @"winsta0\default" : null,
};
var commandLine = BuildCommandLine(opts.FileName, opts.Arguments);
if (!CreateProcessAsUser(
primaryToken,
null,
commandLine,
IntPtr.Zero, IntPtr.Zero,
bInheritHandles: true,
CREATE_UNICODE_ENVIRONMENT | CREATE_NO_WINDOW | NORMAL_PRIORITY_CLASS,
envBlock,
string.IsNullOrEmpty(opts.WorkingDirectory) ? null : opts.WorkingDirectory,
ref si,
out pi))
{
throw LastError("CreateProcessAsUser");
}
// Close child-side handles in our process so EOF propagates when the child exits.
CloseHandle(stdoutWrite); stdoutWrite = IntPtr.Zero;
CloseHandle(stderrWrite); stderrWrite = IntPtr.Zero;
CloseHandle(stdinRead); stdinRead = IntPtr.Zero;
// Pipe stdin if provided, then close the write end.
if (opts.StdinBytes is { Length: > 0 })
{
using var stdinStream = new FileStream(new SafeFileHandle(stdinWrite, ownsHandle: true), FileAccess.Write);
stdinStream.Write(opts.StdinBytes, 0, opts.StdinBytes.Length);
stdinStream.Flush();
stdinWrite = IntPtr.Zero; // ownership transferred to the FileStream
}
else
{
CloseHandle(stdinWrite); stdinWrite = IntPtr.Zero;
}
CloseHandle(pi.hThread);
var stdout = new StreamReader(new FileStream(new SafeFileHandle(stdoutRead, true), FileAccess.Read), Encoding.UTF8);
var stderr = new StreamReader(new FileStream(new SafeFileHandle(stderrRead, true), FileAccess.Read), Encoding.UTF8);
stdoutRead = IntPtr.Zero;
stderrRead = IntPtr.Zero;
succeeded = true;
return new LaunchResult
{
ProcessHandle = pi.hProcess,
ProcessId = pi.dwProcessId,
Stdout = stdout,
Stderr = stderr,
};
}
finally
{
if (primaryToken != IntPtr.Zero) CloseHandle(primaryToken);
if (envBlock != IntPtr.Zero) DestroyEnvironmentBlock(envBlock);
if (!succeeded)
{
if (stdoutRead != IntPtr.Zero) CloseHandle(stdoutRead);
if (stdoutWrite != IntPtr.Zero) CloseHandle(stdoutWrite);
if (stderrRead != IntPtr.Zero) CloseHandle(stderrRead);
if (stderrWrite != IntPtr.Zero) CloseHandle(stderrWrite);
if (stdinRead != IntPtr.Zero) CloseHandle(stdinRead);
if (stdinWrite != IntPtr.Zero) CloseHandle(stdinWrite);
if (pi.hProcess != IntPtr.Zero) CloseHandle(pi.hProcess);
if (pi.hThread != IntPtr.Zero) CloseHandle(pi.hThread);
}
}
}
public static async Task<int> WaitAsync(IntPtr processHandle, TimeSpan timeout, CancellationToken ct)
{
// Poll WaitForSingleObject in 100ms slices to honor cancellation/timeout cheaply.
var deadline = DateTime.UtcNow + timeout;
while (true)
{
ct.ThrowIfCancellationRequested();
var remaining = deadline - DateTime.UtcNow;
if (remaining <= TimeSpan.Zero) throw new TimeoutException();
var slice = (uint)Math.Min(100, (int)remaining.TotalMilliseconds);
var result = WaitForSingleObject(processHandle, slice);
if (result == 0) // WAIT_OBJECT_0
{
if (!GetExitCodeProcess(processHandle, out var code))
throw LastError("GetExitCodeProcess");
return (int)code;
}
if (result == 0xFFFFFFFF)
throw LastError("WaitForSingleObject");
await Task.Yield();
}
}
public static void Kill(IntPtr processHandle)
{
try { TerminateProcess(processHandle, 1); } catch { }
}
private static void CreateInheritablePipe(out IntPtr read, out IntPtr write, bool parentReads)
{
var sa = new SECURITY_ATTRIBUTES
{
nLength = Marshal.SizeOf<SECURITY_ATTRIBUTES>(),
bInheritHandle = 1,
lpSecurityDescriptor = IntPtr.Zero,
};
if (!CreatePipe(out read, out write, ref sa, 0))
throw LastError("CreatePipe");
// Mark the parent's end non-inheritable so it's not duplicated into the child.
var parentEnd = parentReads ? read : write;
if (!SetHandleInformation(parentEnd, HANDLE_FLAG_INHERIT, 0))
throw LastError("SetHandleInformation");
}
private static IntPtr AppendEnvVars(IntPtr existingBlock, IReadOnlyDictionary<string, string> extras)
{
// The existing block is a sequence of null-terminated WCHAR strings ending with
// an extra null. Walk it byte-pair by byte-pair to find the end.
var parsed = new List<string>();
var offset = 0;
while (true)
{
var ch0 = Marshal.ReadInt16(existingBlock, offset);
if (ch0 == 0) break;
var entry = Marshal.PtrToStringUni(existingBlock + offset)!;
parsed.Add(entry);
offset += (entry.Length + 1) * sizeof(char);
}
DestroyEnvironmentBlock(existingBlock);
var combined = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var entry in parsed)
{
var eq = entry.IndexOf('=');
if (eq <= 0) continue;
combined[entry.Substring(0, eq)] = entry.Substring(eq + 1);
}
foreach (var (k, v) in extras) combined[k] = v;
var sb = new StringBuilder();
foreach (var (k, v) in combined)
{
sb.Append(k).Append('=').Append(v).Append('\0');
}
sb.Append('\0');
var bytes = Encoding.Unicode.GetBytes(sb.ToString());
var ptr = Marshal.AllocHGlobal(bytes.Length);
Marshal.Copy(bytes, 0, ptr, bytes.Length);
return ptr;
}
/// <summary>
/// Build a Windows command line string from a filename + arg list using the
/// quoting rules consumed by CommandLineToArgvW.
/// </summary>
private static string BuildCommandLine(string fileName, IReadOnlyList<string> args)
{
var sb = new StringBuilder();
AppendArg(sb, fileName);
foreach (var arg in args)
{
sb.Append(' ');
AppendArg(sb, arg);
}
return sb.ToString();
}
private static void AppendArg(StringBuilder sb, string arg)
{
// Empty arg → "".
if (arg.Length == 0) { sb.Append("\"\""); return; }
var needsQuoting = arg.IndexOfAny(new[] { ' ', '\t', '\n', '\v', '"' }) >= 0;
if (!needsQuoting) { sb.Append(arg); return; }
sb.Append('"');
var backslashes = 0;
foreach (var ch in arg)
{
if (ch == '\\') { backslashes++; continue; }
if (ch == '"')
{
sb.Append('\\', backslashes * 2 + 1);
sb.Append('"');
backslashes = 0;
continue;
}
if (backslashes > 0) { sb.Append('\\', backslashes); backslashes = 0; }
sb.Append(ch);
}
// Trailing backslashes before closing quote must be doubled.
if (backslashes > 0) sb.Append('\\', backslashes * 2);
sb.Append('"');
}
}
@@ -0,0 +1,157 @@
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
namespace WebhookServer.Core.Execution.Native;
/// <summary>
/// Win32 P/Invoke layer for launching processes in another user's session.
/// Used by <see cref="InteractiveProcessLauncher"/>; not intended for general use.
/// </summary>
[SupportedOSPlatform("windows")]
internal static class NativeMethods
{
public const uint INVALID_SESSION_ID = 0xFFFFFFFF;
public const int MAXIMUM_ALLOWED = 0x02000000;
public const uint CREATE_UNICODE_ENVIRONMENT = 0x00000400;
public const uint CREATE_NO_WINDOW = 0x08000000;
public const uint CREATE_NEW_CONSOLE = 0x00000010;
public const uint NORMAL_PRIORITY_CLASS = 0x00000020;
public const int STARTF_USESTDHANDLES = 0x00000100;
public const int HANDLE_FLAG_INHERIT = 1;
public const uint INFINITE = 0xFFFFFFFF;
public enum SECURITY_IMPERSONATION_LEVEL
{
SecurityAnonymous = 0,
SecurityIdentification = 1,
SecurityImpersonation = 2,
SecurityDelegation = 3,
}
public enum TOKEN_TYPE
{
TokenPrimary = 1,
TokenImpersonation = 2,
}
[StructLayout(LayoutKind.Sequential)]
public struct SECURITY_ATTRIBUTES
{
public int nLength;
public IntPtr lpSecurityDescriptor;
public int bInheritHandle;
}
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
public struct STARTUPINFO
{
public int cb;
public string? lpReserved;
public string? lpDesktop;
public string? lpTitle;
public uint dwX;
public uint dwY;
public uint dwXSize;
public uint dwYSize;
public uint dwXCountChars;
public uint dwYCountChars;
public uint dwFillAttribute;
public uint dwFlags;
public ushort wShowWindow;
public ushort cbReserved2;
public IntPtr lpReserved2;
public IntPtr hStdInput;
public IntPtr hStdOutput;
public IntPtr hStdError;
}
[StructLayout(LayoutKind.Sequential)]
public struct PROCESS_INFORMATION
{
public IntPtr hProcess;
public IntPtr hThread;
public uint dwProcessId;
public uint dwThreadId;
}
public const int LOGON32_LOGON_INTERACTIVE = 2;
public const int LOGON32_LOGON_BATCH = 4;
public const int LOGON32_PROVIDER_DEFAULT = 0;
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
public static extern bool LogonUser(
string lpszUsername,
string? lpszDomain,
string lpszPassword,
int dwLogonType,
int dwLogonProvider,
out IntPtr phToken);
[DllImport("kernel32.dll")]
public static extern uint WTSGetActiveConsoleSessionId();
[DllImport("wtsapi32.dll", SetLastError = true)]
public static extern bool WTSQueryUserToken(uint sessionId, out IntPtr phToken);
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
public static extern bool DuplicateTokenEx(
IntPtr hExistingToken,
uint dwDesiredAccess,
IntPtr lpTokenAttributes,
SECURITY_IMPERSONATION_LEVEL impersonationLevel,
TOKEN_TYPE tokenType,
out IntPtr phNewToken);
[DllImport("userenv.dll", SetLastError = true)]
public static extern bool CreateEnvironmentBlock(
out IntPtr lpEnvironment,
IntPtr hToken,
bool bInherit);
[DllImport("userenv.dll", SetLastError = true)]
public static extern bool DestroyEnvironmentBlock(IntPtr lpEnvironment);
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
public static extern bool CreateProcessAsUser(
IntPtr hToken,
string? lpApplicationName,
string? lpCommandLine,
IntPtr lpProcessAttributes,
IntPtr lpThreadAttributes,
bool bInheritHandles,
uint dwCreationFlags,
IntPtr lpEnvironment,
string? lpCurrentDirectory,
ref STARTUPINFO lpStartupInfo,
out PROCESS_INFORMATION lpProcessInformation);
[DllImport("kernel32.dll", SetLastError = true)]
public static extern bool CreatePipe(
out IntPtr hReadPipe,
out IntPtr hWritePipe,
ref SECURITY_ATTRIBUTES lpPipeAttributes,
uint nSize);
[DllImport("kernel32.dll", SetLastError = true)]
public static extern bool SetHandleInformation(IntPtr hObject, int dwMask, int dwFlags);
[DllImport("kernel32.dll", SetLastError = true)]
public static extern bool CloseHandle(IntPtr hObject);
[DllImport("kernel32.dll", SetLastError = true)]
public static extern bool GetExitCodeProcess(IntPtr hProcess, out uint lpExitCode);
[DllImport("kernel32.dll", SetLastError = true)]
public static extern uint WaitForSingleObject(IntPtr hHandle, uint dwMilliseconds);
[DllImport("kernel32.dll", SetLastError = true)]
public static extern bool TerminateProcess(IntPtr hProcess, uint uExitCode);
public static Win32Exception LastError(string what) =>
new(Marshal.GetLastWin32Error(), $"{what} failed");
}
@@ -1,9 +1,12 @@
using System.Diagnostics;
using System.Runtime.Versioning;
using System.Text;
using WebhookServer.Core.Execution.Native;
using WebhookServer.Core.Models;
namespace WebhookServer.Core.Execution;
[SupportedOSPlatform("windows")]
public sealed class ProcessExecutor : IExecutor
{
/// <summary>Per-stream cap on captured output (excess is dropped and StdoutTruncated set).</summary>
@@ -12,23 +15,35 @@ public sealed class ProcessExecutor : IExecutor
public async Task<ExecutionResult> RunAsync(EndpointConfig endpoint, ExecutionContext ctx, CancellationToken ct)
{
var startedAt = DateTimeOffset.UtcNow;
var psi = BuildStartInfo(endpoint, ctx);
var mode = endpoint.RunAs?.Mode ?? RunAsMode.Service;
return mode switch
{
RunAsMode.InteractiveUser => await RunWithLauncherAsync(endpoint, ctx, startedAt, useActiveConsole: true, ct).ConfigureAwait(false),
RunAsMode.SpecificUser => await RunWithLauncherAsync(endpoint, ctx, startedAt, useActiveConsole: false, ct).ConfigureAwait(false),
_ => await RunWithProcessAsync(endpoint, ctx, startedAt, ct).ConfigureAwait(false),
};
}
// ---------------- Process path: handles Service (default) and SpecificUser. ----------------
private async Task<ExecutionResult> RunWithProcessAsync(EndpointConfig endpoint, ExecutionContext ctx, DateTimeOffset startedAt, CancellationToken ct)
{
var (psi, envVars) = BuildStartInfo(endpoint, ctx);
foreach (var (k, v) in envVars)
psi.Environment[k] = v;
using var process = new Process { StartInfo = psi, EnableRaisingEvents = true };
try
{
if (!process.Start())
{
return Failed(ctx.RunId, startedAt, "process failed to start");
}
}
catch (Exception ex)
{
return Failed(ctx.RunId, startedAt, $"launch error: {ex.Message}");
}
// stdin
if (endpoint.DataPassing.StdinJson)
{
try
@@ -42,15 +57,14 @@ public sealed class ProcessExecutor : IExecutor
}
finally
{
try { process.StandardInput.Close(); } catch { /* swallow */ }
try { process.StandardInput.Close(); } catch { }
}
}
else
{
try { process.StandardInput.Close(); } catch { /* swallow */ }
try { process.StandardInput.Close(); } catch { }
}
// Capture stdout/stderr in parallel, with per-stream cap.
var stdoutTask = ReadCappedAsync(process.StandardOutput, ct);
var stderrTask = ReadCappedAsync(process.StandardError, ct);
@@ -66,8 +80,8 @@ public sealed class ProcessExecutor : IExecutor
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
timedOut = true;
try { process.Kill(entireProcessTree: true); } catch { /* swallow */ }
try { await process.WaitForExitAsync(CancellationToken.None).ConfigureAwait(false); } catch { /* swallow */ }
try { process.Kill(entireProcessTree: true); } catch { }
try { await process.WaitForExitAsync(CancellationToken.None).ConfigureAwait(false); } catch { }
}
var (stdout, stdoutTrunc) = await stdoutTask.ConfigureAwait(false);
@@ -87,7 +101,87 @@ public sealed class ProcessExecutor : IExecutor
};
}
private static ProcessStartInfo BuildStartInfo(EndpointConfig endpoint, ExecutionContext ctx)
// ---------------- Token-based path: InteractiveUser + SpecificUser. ----------------
private static async Task<ExecutionResult> RunWithLauncherAsync(EndpointConfig endpoint, ExecutionContext ctx, DateTimeOffset startedAt, bool useActiveConsole, CancellationToken ct)
{
var (psi, envVars) = BuildStartInfo(endpoint, ctx);
var opts = new InteractiveProcessLauncher.LaunchOptions
{
FileName = psi.FileName,
Arguments = psi.ArgumentList.ToList(),
WorkingDirectory = string.IsNullOrEmpty(psi.WorkingDirectory) ? null : psi.WorkingDirectory,
ExtraEnvVars = envVars,
StdinBytes = endpoint.DataPassing.StdinJson ? ctx.BodyBytes : null,
};
InteractiveProcessLauncher.LaunchResult launch;
try
{
if (useActiveConsole)
{
launch = InteractiveProcessLauncher.LaunchAsActiveConsoleUser(opts);
}
else
{
var runAs = endpoint.RunAs ?? throw new InvalidOperationException("RunAs config missing");
if (string.IsNullOrEmpty(runAs.Username))
return Failed(ctx.RunId, startedAt, "RunAs.Username is required when Mode = SpecificUser");
if (runAs.Password?.Plaintext is not { Length: > 0 } password)
return Failed(ctx.RunId, startedAt, "RunAs.Password is required when Mode = SpecificUser");
var (domain, user) = ParseUserSpec(runAs.Username);
launch = InteractiveProcessLauncher.LaunchAsSpecificUser(user, password, domain, opts);
}
}
catch (Exception ex)
{
return Failed(ctx.RunId, startedAt, $"launch error: {ex.Message}");
}
try
{
var stdoutTask = ReadCappedAsync(launch.Stdout, ct);
var stderrTask = ReadCappedAsync(launch.Stderr, ct);
var timeout = TimeSpan.FromSeconds(Math.Max(1, endpoint.TimeoutSeconds));
bool timedOut = false;
int exitCode = -1;
try
{
exitCode = await InteractiveProcessLauncher.WaitAsync(launch.ProcessHandle, timeout, ct).ConfigureAwait(false);
}
catch (TimeoutException)
{
timedOut = true;
InteractiveProcessLauncher.Kill(launch.ProcessHandle);
}
var (stdout, stdoutTrunc) = await stdoutTask.ConfigureAwait(false);
var (stderr, stderrTrunc) = await stderrTask.ConfigureAwait(false);
return new ExecutionResult
{
RunId = ctx.RunId,
ExitCode = timedOut ? -1 : exitCode,
Stdout = stdout,
Stderr = stderr,
StdoutTruncated = stdoutTrunc,
StderrTruncated = stderrTrunc,
StartedAt = startedAt,
CompletedAt = DateTimeOffset.UtcNow,
TimedOut = timedOut,
};
}
finally
{
launch.Dispose();
}
}
// ---------------- Shared psi construction. ----------------
private static (ProcessStartInfo psi, Dictionary<string, string> envVars) BuildStartInfo(EndpointConfig endpoint, ExecutionContext ctx)
{
var psi = new ProcessStartInfo
{
@@ -131,18 +225,19 @@ public sealed class ProcessExecutor : IExecutor
psi.ArgumentList.Add(arg);
}
var envVars = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["WEBHOOK_RUN_ID"] = ctx.RunId,
["WEBHOOK_SLUG"] = ctx.Slug,
};
if (endpoint.DataPassing.EnvVars)
{
foreach (var (k, v) in ctx.Headers)
psi.Environment[$"WEBHOOK_HEADER_{Sanitize(k)}"] = v;
foreach (var (k, v) in ctx.Query)
psi.Environment[$"WEBHOOK_QUERY_{Sanitize(k)}"] = v;
foreach (var (k, v) in ctx.Headers) envVars[$"WEBHOOK_HEADER_{Sanitize(k)}"] = v;
foreach (var (k, v) in ctx.Query) envVars[$"WEBHOOK_QUERY_{Sanitize(k)}"] = v;
}
psi.Environment["WEBHOOK_RUN_ID"] = ctx.RunId;
psi.Environment["WEBHOOK_SLUG"] = ctx.Slug;
return psi;
return (psi, envVars);
}
private static void AddPwshArgs(ProcessStartInfo psi, EndpointConfig endpoint)
@@ -175,6 +270,13 @@ public sealed class ProcessExecutor : IExecutor
return endpoint.InlineCommand ?? "";
}
private static (string Domain, string User) ParseUserSpec(string spec)
{
var bs = spec.IndexOf('\\');
if (bs > 0) return (spec.Substring(0, bs), spec.Substring(bs + 1));
return ("", spec);
}
private static string Sanitize(string key)
{
var sb = new StringBuilder(key.Length);
@@ -203,7 +305,6 @@ public sealed class ProcessExecutor : IExecutor
catch (IOException) { break; }
if (n == 0) break;
// Cheap byte estimate (ASCII-ish); good enough as a guard rail.
if (!truncated)
{
if (byteEstimate + n > MaxOutputBytes)
@@ -218,7 +319,6 @@ public sealed class ProcessExecutor : IExecutor
byteEstimate += n;
}
}
// Else keep draining without storing to keep the pipe from blocking.
}
return (sb.ToString(), truncated);
@@ -52,6 +52,7 @@ public sealed class StatusInfo
public bool Running { get; set; }
public int HttpPort { get; set; }
public int? HttpsPort { get; set; }
public string? DisplayHost { get; set; }
public DateTimeOffset StartedAt { get; set; }
public int EndpointCount { get; set; }
}
@@ -42,4 +42,6 @@ public sealed class EndpointConfig
public bool Serialize { get; set; }
public CallbackConfig? Callback { get; set; }
public RunAsConfig? RunAs { get; set; }
}
+15
View File
@@ -53,3 +53,18 @@ public enum HttpsBindingKind
PfxFile = 1,
CertStoreThumbprint = 2,
}
public enum RunAsMode
{
/// <summary>Run as whatever account the service itself runs under (default).</summary>
Service = 0,
/// <summary>Run as a specific username + password (batch logon, no UI).</summary>
SpecificUser = 1,
/// <summary>
/// Run in the active console session under whoever is logged in at the keyboard.
/// Lets hooks pop interactive UI on the user's desktop.
/// </summary>
InteractiveUser = 2,
}
@@ -0,0 +1,21 @@
namespace WebhookServer.Core.Models;
public sealed class RunAsConfig
{
public RunAsMode Mode { get; set; } = RunAsMode.Service;
/// <summary>
/// "DOMAIN\user" or "user@upn" or just "user" (local). Required when
/// <see cref="Mode"/> is <see cref="RunAsMode.SpecificUser"/>.
/// </summary>
public string? Username { get; set; }
/// <summary>DPAPI-protected password for SpecificUser mode.</summary>
public ProtectedString? Password { get; set; }
/// <summary>
/// When true, load the user's profile (HKCU + AppData) before running.
/// Slower; only needed for hooks that read user-scope settings.
/// </summary>
public bool LoadProfile { get; set; }
}
@@ -5,6 +5,18 @@ public sealed class ServerConfig
public int HttpPort { get; set; } = 8080;
public HttpsBinding? HttpsBinding { get; set; }
/// <summary>
/// IP addresses Kestrel binds to. Empty = listen on all interfaces (default).
/// Non-empty = listen only on the named addresses.
/// </summary>
public List<string> BindAddresses { get; set; } = new();
/// <summary>
/// Hostname or IP that the GUI uses when constructing webhook URLs to display.
/// Null = "localhost". Has no effect on what Kestrel actually accepts.
/// </summary>
public string? DisplayHost { get; set; }
/// <summary>
/// IPs/CIDRs allowed to set X-Forwarded-For. Empty = forwarded headers are ignored
/// and the direct connection IP is always used.
@@ -55,6 +55,7 @@ public sealed class ConfigStore
{
ClearOne(ep.Bearer?.Secret);
ClearOne(ep.Hmac?.Secret);
ClearOne(ep.RunAs?.Password);
if (ep.Callback is { } cb)
{
ClearOne(cb.Bearer?.Secret);
@@ -76,6 +77,7 @@ public sealed class ConfigStore
{
DecryptOne(ep.Bearer?.Secret);
DecryptOne(ep.Hmac?.Secret);
DecryptOne(ep.RunAs?.Password);
if (ep.Callback is { } cb)
{
DecryptOne(cb.Bearer?.Secret);
@@ -91,6 +93,7 @@ public sealed class ConfigStore
{
EncryptOne(ep.Bearer?.Secret);
EncryptOne(ep.Hmac?.Secret);
EncryptOne(ep.RunAs?.Password);
if (ep.Callback is { } cb)
{
EncryptOne(cb.Bearer?.Secret);
+2
View File
@@ -7,5 +7,7 @@
<conv:NullToBoolConverter x:Key="NotNull"/>
<conv:BoolToBrushConverter x:Key="ConnFill"/>
<conv:StringEqualsConverter x:Key="StringEqualsConverter"/>
<conv:HookUrlConverter x:Key="HookUrl"/>
<conv:InvertBoolConverter x:Key="InvertBool"/>
</Application.Resources>
</Application>
@@ -13,6 +13,30 @@ public sealed class NullToBoolConverter : IValueConverter
=> throw new NotSupportedException();
}
public sealed class HookUrlConverter : IMultiValueConverter
{
public object Convert(object[] values, Type targetType, object? parameter, CultureInfo culture)
{
if (values.Length < 2) return "";
var slug = values[0] as string ?? "";
var baseUrl = values[1] as string ?? "";
if (string.IsNullOrEmpty(baseUrl) || string.IsNullOrEmpty(slug)) return "";
return $"{baseUrl.TrimEnd('/')}/hook/{slug}";
}
public object[] ConvertBack(object? value, Type[] targetTypes, object? parameter, CultureInfo culture)
=> throw new NotSupportedException();
}
public sealed class InvertBoolConverter : IValueConverter
{
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
=> value is bool b && !b;
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> value is bool b && !b;
}
public sealed class StringEqualsConverter : IValueConverter
{
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+65 -13
View File
@@ -8,6 +8,9 @@
mc:Ignorable="d"
Title="Webhook Server" Height="600" Width="1000"
d:DataContext="{d:DesignInstance Type=vm:MainViewModel}">
<Window.InputBindings>
<KeyBinding Key="N" Modifiers="Control" Command="{Binding AddEndpointCommand}"/>
</Window.InputBindings>
<DockPanel LastChildFill="True">
<StatusBar DockPanel.Dock="Bottom">
<StatusBarItem>
@@ -19,17 +22,25 @@
</StatusBarItem>
</StatusBar>
<ToolBar DockPanel.Dock="Top">
<Button Content="Refresh" Command="{Binding RefreshCommand}"/>
<Menu DockPanel.Dock="Top">
<MenuItem Header="_File">
<MenuItem Header="_New endpoint…" Command="{Binding AddEndpointCommand}" InputGestureText="Ctrl+N"/>
<Separator/>
<Button Content="Add" Command="{Binding AddEndpointCommand}"/>
<Button Content="Edit" Command="{Binding EditEndpointCommand}"
IsEnabled="{Binding SelectedEndpoint, Converter={StaticResource NotNull}}"/>
<Button Content="Delete" Command="{Binding DeleteEndpointCommand}"
IsEnabled="{Binding SelectedEndpoint, Converter={StaticResource NotNull}}"/>
<MenuItem Header="_Import config…" IsEnabled="False" ToolTip="Coming soon"/>
<MenuItem Header="_Export config…" IsEnabled="False" ToolTip="Coming soon"/>
<MenuItem Header="_Backups" IsEnabled="False" ToolTip="Coming soon"/>
<Separator/>
<Button Content="Server Settings…" Command="{Binding EditServerSettingsCommand}"/>
</ToolBar>
<MenuItem Header="E_xit" Command="{Binding ExitCommand}"/>
</MenuItem>
<MenuItem Header="_Server">
<MenuItem Header="_Settings…" Command="{Binding EditServerSettingsCommand}"/>
<Separator/>
<MenuItem Header="_Restart service" Command="{Binding RestartServiceCommand}"/>
</MenuItem>
<MenuItem Header="_Help">
<MenuItem Header="_About Webhook Server…" Command="{Binding ShowAboutCommand}"/>
</MenuItem>
</Menu>
<Grid>
<Grid.RowDefinitions>
@@ -46,6 +57,25 @@
CanUserDeleteRows="False"
IsReadOnly="True"
HeadersVisibility="Column">
<DataGrid.RowStyle>
<Style TargetType="DataGridRow">
<EventSetter Event="MouseDoubleClick" Handler="OnRowDoubleClick"/>
<Setter Property="ContextMenu">
<Setter.Value>
<ContextMenu>
<MenuItem Header="_Edit…" Command="{Binding DataContext.EditEndpointCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
<MenuItem Header="_Copy URL" Command="{Binding DataContext.CopyEndpointUrlCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
<Separator/>
<MenuItem Header="Toggle _enabled"
Command="{Binding DataContext.ToggleEnabledCommand, RelativeSource={RelativeSource AncestorType=Window}}"
CommandParameter="{Binding}"/>
<Separator/>
<MenuItem Header="_Delete…" Command="{Binding DataContext.DeleteEndpointCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
</ContextMenu>
</Setter.Value>
</Setter>
</Style>
</DataGrid.RowStyle>
<DataGrid.Columns>
<DataGridTemplateColumn Header="Enabled" Width="80">
<DataGridTemplateColumn.CellTemplate>
@@ -56,7 +86,25 @@
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTextColumn Header="Slug" Binding="{Binding Slug}" Width="*"/>
<DataGridTextColumn Header="Slug" Binding="{Binding Slug}" Width="120"/>
<DataGridTemplateColumn Header="URL" Width="*">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate DataType="{x:Type models:EndpointConfig}">
<TextBox IsReadOnly="True"
BorderThickness="0"
Background="Transparent"
Padding="0"
VerticalAlignment="Center">
<TextBox.Text>
<MultiBinding Converter="{StaticResource HookUrl}" Mode="OneWay">
<Binding Path="Slug"/>
<Binding Path="DataContext.HttpBaseUrl" RelativeSource="{RelativeSource AncestorType=Window}"/>
</MultiBinding>
</TextBox.Text>
</TextBox>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTextColumn Header="Auth" Binding="{Binding AuthMode}" Width="80"/>
<DataGridTextColumn Header="Executor" Binding="{Binding ExecutorType}" Width="140"/>
<DataGridTextColumn Header="Mode" Binding="{Binding ResponseMode}" Width="80"/>
@@ -71,16 +119,20 @@
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Text="Recent log entries" FontWeight="Bold" Margin="6,4"/>
<Button Grid.Column="1" Content="Refresh" Command="{Binding RefreshLogTailCommand}" Margin="6,2"/>
<CheckBox Grid.Column="1" Content="Auto-scroll" IsChecked="{Binding AutoScrollLogs}" VerticalAlignment="Center" Margin="6,2"/>
<Button Grid.Column="2" Content="Refresh" Command="{Binding RefreshLogTailCommand}" Margin="6,2"/>
</Grid>
<TextBox Text="{Binding LogTail, Mode=OneWay}"
<TextBox x:Name="LogTailBox"
Text="{Binding LogTail, Mode=OneWay}"
IsReadOnly="True"
FontFamily="Consolas"
VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Auto"
TextWrapping="NoWrap"/>
TextWrapping="NoWrap"
TextChanged="OnLogTailChanged"/>
</DockPanel>
</Grid>
</DockPanel>
+14
View File
@@ -1,4 +1,6 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using WebhookServer.Gui.Services;
using WebhookServer.Gui.ViewModels;
@@ -13,4 +15,16 @@ public partial class MainWindow : Window
DataContext = vm;
Loaded += async (_, _) => await vm.RefreshCommand.ExecuteAsync(null);
}
private void OnLogTailChanged(object sender, TextChangedEventArgs e)
{
if (DataContext is MainViewModel vm && vm.AutoScrollLogs && sender is TextBox box)
box.ScrollToEnd();
}
private void OnRowDoubleClick(object sender, MouseButtonEventArgs e)
{
if (DataContext is MainViewModel vm && vm.EditEndpointCommand.CanExecute(null))
vm.EditEndpointCommand.Execute(null);
}
}
@@ -1,5 +1,6 @@
using System.Runtime.Versioning;
using System.Text.Json;
using System.Windows;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using WebhookServer.Core.Models;
@@ -22,6 +23,8 @@ public sealed partial class EndpointEditorViewModel : ObservableObject
Endpoint = JsonSerializer.Deserialize<EndpointConfig>(json, ConfigJson.Compact)!;
Endpoint.Bearer ??= new BearerOptions();
Endpoint.Hmac ??= new HmacOptions();
Endpoint.RunAs ??= new RunAsConfig();
Endpoint.RunAs.Password ??= new ProtectedString();
IsNew = isNew;
}
@@ -29,6 +32,81 @@ public sealed partial class EndpointEditorViewModel : ObservableObject
public Array ExecutorTypes { get; } = Enum.GetValues(typeof(ExecutorType));
public Array ResponseModes { get; } = Enum.GetValues(typeof(ResponseMode));
/// <summary>
/// Proxy for <see cref="EndpointConfig.AuthMode"/> that emits change notifications
/// for the visibility flags so the bearer/HMAC sections show/hide reactively.
/// </summary>
public AuthMode SelectedAuthMode
{
get => Endpoint.AuthMode;
set
{
if (Endpoint.AuthMode == value) return;
Endpoint.AuthMode = value;
OnPropertyChanged();
OnPropertyChanged(nameof(BearerVisible));
OnPropertyChanged(nameof(HmacVisible));
}
}
public Visibility BearerVisible =>
Endpoint.AuthMode == AuthMode.Bearer ? Visibility.Visible : Visibility.Collapsed;
public Visibility HmacVisible =>
Endpoint.AuthMode == AuthMode.Hmac ? Visibility.Visible : Visibility.Collapsed;
public Array RunAsModes { get; } = Enum.GetValues(typeof(RunAsMode));
public RunAsMode SelectedRunAsMode
{
get => Endpoint.RunAs?.Mode ?? RunAsMode.Service;
set
{
Endpoint.RunAs ??= new RunAsConfig();
if (Endpoint.RunAs.Mode == value) return;
Endpoint.RunAs.Mode = value;
OnPropertyChanged();
OnPropertyChanged(nameof(SpecificUserVisible));
}
}
public Visibility SpecificUserVisible =>
SelectedRunAsMode == RunAsMode.SpecificUser ? Visibility.Visible : Visibility.Collapsed;
public string RunAsUsername
{
get => Endpoint.RunAs?.Username ?? "";
set
{
Endpoint.RunAs ??= new RunAsConfig();
Endpoint.RunAs.Username = string.IsNullOrEmpty(value) ? null : value;
OnPropertyChanged();
}
}
public string RunAsPassword
{
get => Endpoint.RunAs?.Password?.Plaintext ?? "";
set
{
Endpoint.RunAs ??= new RunAsConfig();
Endpoint.RunAs.Password ??= new ProtectedString();
Endpoint.RunAs.Password.Plaintext = string.IsNullOrEmpty(value) ? null : value;
OnPropertyChanged();
}
}
public bool RunAsLoadProfile
{
get => Endpoint.RunAs?.LoadProfile ?? false;
set
{
Endpoint.RunAs ??= new RunAsConfig();
Endpoint.RunAs.LoadProfile = value;
OnPropertyChanged();
}
}
public string AllowedClientsText
{
get => string.Join(Environment.NewLine, Endpoint.AllowedClients);
@@ -49,9 +127,9 @@ public sealed partial class EndpointEditorViewModel : ObservableObject
}
}
public string BearerSecretInput
public string BearerSecret
{
get => "";
get => Endpoint.Bearer?.Secret.Plaintext ?? "";
set
{
Endpoint.Bearer ??= new BearerOptions();
@@ -60,9 +138,9 @@ public sealed partial class EndpointEditorViewModel : ObservableObject
}
}
public string HmacSecretInput
public string HmacSecret
{
get => "";
get => Endpoint.Hmac?.Secret.Plaintext ?? "";
set
{
Endpoint.Hmac ??= new HmacOptions();
@@ -4,6 +4,7 @@ using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using WebhookServer.Core.Ipc;
@@ -24,11 +25,19 @@ public sealed partial class MainViewModel : ObservableObject
[ObservableProperty] private string _connectionStatus = "Disconnected";
[ObservableProperty] private bool _isConnected;
[ObservableProperty] private string _logTail = "";
[ObservableProperty] private bool _autoScrollLogs = true;
[ObservableProperty] private ServerConfig _serverConfig = new();
[ObservableProperty] private string _httpBaseUrl = "http://localhost:8080";
[ObservableProperty] private string? _httpsBaseUrl;
private readonly DispatcherTimer _logTimer;
public MainViewModel(AdminPipeClient client)
{
_client = client;
_logTimer = new DispatcherTimer(DispatcherPriority.Background) { Interval = TimeSpan.FromSeconds(3) };
_logTimer.Tick += async (_, _) => await RefreshLogTailAsync();
_logTimer.Start();
}
[RelayCommand]
@@ -46,6 +55,13 @@ public sealed partial class MainViewModel : ObservableObject
? $"Connected — HTTP {status!.HttpPort}{(status.HttpsPort.HasValue ? $" / HTTPS {status.HttpsPort}" : "")}"
: "Disconnected";
if (status is not null)
{
var host = string.IsNullOrEmpty(status.DisplayHost) ? "localhost" : status.DisplayHost;
HttpBaseUrl = $"http://{host}:{status.HttpPort}";
HttpsBaseUrl = status.HttpsPort.HasValue ? $"https://{host}:{status.HttpsPort.Value}" : null;
}
Endpoints.Clear();
if (config is not null)
{
@@ -159,6 +175,56 @@ public sealed partial class MainViewModel : ObservableObject
}
}
[RelayCommand]
private async Task RestartServiceAsync()
{
var ok = MessageBox.Show(
"Restart the WebhookServer service? In-flight requests will be aborted.",
"Restart service",
MessageBoxButton.OKCancel,
MessageBoxImage.Warning);
if (ok != MessageBoxResult.OK) return;
try
{
await _client.RestartListenerAsync().ConfigureAwait(false);
await Task.Delay(2000).ConfigureAwait(false);
await RefreshAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
ShowError("Restart failed", ex);
}
}
[RelayCommand]
private void ShowAbout()
{
var dlg = new Views.AboutDialog { Owner = Application.Current.MainWindow };
dlg.ShowDialog();
}
[RelayCommand]
private void Exit()
{
Application.Current.Shutdown();
}
[RelayCommand]
private void CopyEndpointUrl()
{
if (SelectedEndpoint is null || string.IsNullOrEmpty(HttpBaseUrl)) return;
var url = $"{HttpBaseUrl.TrimEnd('/')}/hook/{SelectedEndpoint.Slug}";
try
{
Clipboard.SetText(url);
}
catch (Exception ex)
{
ShowError("Copy failed", ex);
}
}
[RelayCommand]
private async Task EditServerSettingsAsync()
{
@@ -172,6 +238,8 @@ public sealed partial class MainViewModel : ObservableObject
ServerConfig.HttpPort = vm.HttpPort;
ServerConfig.TrustedProxies = vm.TrustedProxiesList;
ServerConfig.HttpsBinding = vm.BuildBinding();
ServerConfig.BindAddresses = vm.BindAddressesList;
ServerConfig.DisplayHost = vm.DisplayHostValue;
await _client.InvokeAsync(AdminOps.UpdateConfig, ServerConfig).ConfigureAwait(false);
await RefreshAsync().ConfigureAwait(false);
}
@@ -1,3 +1,7 @@
using System.Collections.ObjectModel;
using System.Net;
using System.Net.NetworkInformation;
using System.Net.Sockets;
using System.Runtime.Versioning;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
@@ -13,9 +17,17 @@ public sealed partial class ServerSettingsViewModel : ObservableObject
[ObservableProperty] private bool _httpsEnabled;
[ObservableProperty] private string _httpsMode = "PfxFile";
[ObservableProperty] private string _pfxPath = "";
[ObservableProperty] private string _pfxPasswordInput = "";
[ObservableProperty] private string _pfxPassword = "";
[ObservableProperty] private string _thumbprint = "";
[ObservableProperty] private string _trustedProxiesText = "";
[ObservableProperty] private bool _listenAllInterfaces = true;
[ObservableProperty] private string _displayHost = "localhost";
/// <summary>One row per detected local IPv4/IPv6 address. Bound for "listen on" checkboxes.</summary>
public ObservableCollection<NetworkAddressRow> Addresses { get; } = new();
/// <summary>Suggestions for the Display URL host dropdown (detected IPs + localhost + machine name).</summary>
public ObservableCollection<string> DisplayHostChoices { get; } = new();
public bool Accepted { get; private set; }
@@ -29,12 +41,50 @@ public sealed partial class ServerSettingsViewModel : ObservableObject
HttpsPort = b?.Port ?? 8443;
HttpsMode = b?.Kind == HttpsBindingKind.CertStoreThumbprint ? "Thumbprint" : "PfxFile";
PfxPath = b?.PfxPath ?? "";
PfxPassword = b?.PfxPassword?.Plaintext ?? "";
Thumbprint = b?.Thumbprint ?? "";
var detected = DetectLocalAddresses();
var alreadyBound = new HashSet<string>(config.BindAddresses, StringComparer.OrdinalIgnoreCase);
ListenAllInterfaces = config.BindAddresses.Count == 0;
foreach (var (addr, label) in detected)
{
Addresses.Add(new NetworkAddressRow
{
Address = addr,
Label = label,
IsBound = !ListenAllInterfaces && alreadyBound.Contains(addr),
});
}
// Surface any persisted address that isn't currently detected (e.g. a NIC unplugged
// since save) so the user can keep or remove it explicitly.
foreach (var entry in config.BindAddresses)
{
if (Addresses.Any(a => string.Equals(a.Address, entry, StringComparison.OrdinalIgnoreCase))) continue;
Addresses.Add(new NetworkAddressRow { Address = entry, Label = "(not currently present)", IsBound = true });
}
DisplayHostChoices.Add("localhost");
DisplayHostChoices.Add(Environment.MachineName);
foreach (var (addr, _) in detected)
if (!DisplayHostChoices.Contains(addr))
DisplayHostChoices.Add(addr);
DisplayHost = string.IsNullOrEmpty(config.DisplayHost) ? "localhost" : config.DisplayHost;
}
public List<string> TrustedProxiesList =>
(TrustedProxiesText ?? "").Split(new[] { '\r', '\n', ',' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
public List<string> BindAddressesList =>
ListenAllInterfaces
? new List<string>()
: Addresses.Where(a => a.IsBound).Select(a => a.Address).ToList();
public string? DisplayHostValue =>
string.IsNullOrEmpty(DisplayHost) || DisplayHost == "localhost" ? null : DisplayHost.Trim();
public HttpsBinding? BuildBinding()
{
if (!HttpsEnabled) return null;
@@ -49,12 +99,37 @@ public sealed partial class ServerSettingsViewModel : ObservableObject
{
binding.Kind = HttpsBindingKind.PfxFile;
binding.PfxPath = PfxPath;
if (!string.IsNullOrEmpty(PfxPasswordInput))
binding.PfxPassword = ProtectedString.FromPlaintext(PfxPasswordInput);
if (!string.IsNullOrEmpty(PfxPassword))
binding.PfxPassword = ProtectedString.FromPlaintext(PfxPassword);
}
return binding;
}
[RelayCommand]
private void Save() => Accepted = true;
private static IEnumerable<(string Address, string Label)> DetectLocalAddresses()
{
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var ni in NetworkInterface.GetAllNetworkInterfaces())
{
if (ni.OperationalStatus != OperationalStatus.Up) continue;
if (ni.NetworkInterfaceType == NetworkInterfaceType.Tunnel) continue;
foreach (var ua in ni.GetIPProperties().UnicastAddresses)
{
if (ua.Address.AddressFamily != AddressFamily.InterNetwork &&
ua.Address.AddressFamily != AddressFamily.InterNetworkV6) continue;
var key = ua.Address.ToString();
if (!seen.Add(key)) continue;
yield return (key, $"{ni.Name} ({ni.NetworkInterfaceType})");
}
}
}
}
public sealed partial class NetworkAddressRow : ObservableObject
{
public required string Address { get; init; }
public required string Label { get; init; }
[ObservableProperty] private bool _isBound;
}
@@ -0,0 +1,42 @@
<Window x:Class="WebhookServer.Gui.Views.AboutDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="About Webhook Server"
Height="320" Width="420"
ResizeMode="NoResize"
WindowStartupLocation="CenterOwner"
ShowInTaskbar="False">
<Grid Margin="20">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Text="Webhook Server" FontSize="22" FontWeight="Bold"/>
<TextBlock Grid.Row="1" x:Name="VersionText" Foreground="Gray" Margin="0,4,0,16"/>
<TextBlock Grid.Row="2" TextWrapping="Wrap">
A Windows-native webhook server that runs PowerShell, cmd, or arbitrary
executables in response to incoming HTTP requests.
</TextBlock>
<StackPanel Grid.Row="3" Margin="0,16,0,0">
<TextBlock>
<Run Text="Created by"/>
<Run Text="Justin Paul" FontWeight="SemiBold"/>
</TextBlock>
<TextBlock Margin="0,4,0,0">
<Hyperlink NavigateUri="https://jpaul.me" RequestNavigate="OnHyperlink">https://jpaul.me</Hyperlink>
</TextBlock>
<TextBlock Margin="0,4,0,0">
<Hyperlink NavigateUri="https://github.com/recklessop/webhook-server" RequestNavigate="OnHyperlink">github.com/recklessop/webhook-server</Hyperlink>
</TextBlock>
</StackPanel>
<Button Grid.Row="5" Content="OK" Width="80" HorizontalAlignment="Right" IsDefault="True" IsCancel="True" Click="OnOk"/>
</Grid>
</Window>
@@ -0,0 +1,28 @@
using System.Diagnostics;
using System.Reflection;
using System.Windows;
using System.Windows.Navigation;
namespace WebhookServer.Gui.Views;
public partial class AboutDialog : Window
{
public AboutDialog()
{
InitializeComponent();
var asm = Assembly.GetExecutingAssembly();
var info = asm.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion
?? asm.GetName().Version?.ToString()
?? "0.0.0";
VersionText.Text = $"Version {info}";
}
private void OnHyperlink(object sender, RequestNavigateEventArgs e)
{
Process.Start(new ProcessStartInfo(e.Uri.AbsoluteUri) { UseShellExecute = true });
e.Handled = true;
}
private void OnOk(object sender, RoutedEventArgs e) => Close();
}
@@ -47,23 +47,28 @@
</Grid.ColumnDefinitions>
<TextBlock Text="Mode" VerticalAlignment="Center"/>
<ComboBox Grid.Column="1" ItemsSource="{Binding AuthModes}"
SelectedItem="{Binding Endpoint.AuthMode, Mode=TwoWay}"/>
SelectedItem="{Binding SelectedAuthMode, Mode=TwoWay}"/>
</Grid>
<Grid Margin="0,4,0,0">
<Grid Margin="0,4,0,0" Visibility="{Binding BearerVisible}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="120"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Text="Bearer secret" VerticalAlignment="Center"/>
<PasswordBox Grid.Column="1" PasswordChanged="OnBearerPasswordChanged"/>
<TextBox Grid.Column="1" Text="{Binding BearerSecret, UpdateSourceTrigger=PropertyChanged}" FontFamily="Consolas"/>
<Button Grid.Column="2" Content="Copy" Margin="4,0,0,0" Padding="6,0" Click="OnCopyBearer"/>
</Grid>
<StackPanel Visibility="{Binding HmacVisible}">
<Grid Margin="0,4,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="120"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Text="HMAC secret" VerticalAlignment="Center"/>
<PasswordBox Grid.Column="1" PasswordChanged="OnHmacPasswordChanged"/>
<TextBox Grid.Column="1" Text="{Binding HmacSecret, UpdateSourceTrigger=PropertyChanged}" FontFamily="Consolas"/>
<Button Grid.Column="2" Content="Copy" Margin="4,0,0,0" Padding="6,0" Click="OnCopyHmac"/>
</Grid>
<Grid Margin="0,4,0,0">
<Grid.ColumnDefinitions>
@@ -74,6 +79,7 @@
<TextBox Grid.Column="1" Text="{Binding Endpoint.Hmac.HeaderName, UpdateSourceTrigger=PropertyChanged}"/>
</Grid>
</StackPanel>
</StackPanel>
</GroupBox>
<GroupBox Header="IP allowlist (one per line, IP or CIDR)" Padding="6" Margin="0,8,0,0">
@@ -124,6 +130,50 @@
</StackPanel>
</GroupBox>
<GroupBox Header="Run as" Padding="6" Margin="0,8,0,0">
<StackPanel>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="120"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBlock Text="Identity" VerticalAlignment="Center"/>
<ComboBox Grid.Column="1" ItemsSource="{Binding RunAsModes}" SelectedItem="{Binding SelectedRunAsMode, Mode=TwoWay}"/>
</Grid>
<TextBlock Foreground="Gray" FontStyle="Italic" FontSize="11" Margin="120,2,0,0"
Text="Service = run as the service account (default). InteractiveUser = the logged-in user's desktop session, lets hooks pop UI. SpecificUser = a named account with password."/>
<StackPanel Visibility="{Binding SpecificUserVisible}">
<Grid Margin="0,6,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="120"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBlock Text="Username" VerticalAlignment="Center"/>
<TextBox Grid.Column="1" Text="{Binding RunAsUsername, UpdateSourceTrigger=PropertyChanged}" FontFamily="Consolas"/>
</Grid>
<TextBlock Foreground="Gray" FontStyle="Italic" FontSize="11" Margin="120,2,0,0"
Text="Examples: DOMAIN\justin .\local-user user@contoso.com"/>
<Grid Margin="0,4,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="120"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBlock Text="Password" VerticalAlignment="Center"/>
<TextBox Grid.Column="1" Text="{Binding RunAsPassword, UpdateSourceTrigger=PropertyChanged}" FontFamily="Consolas"/>
</Grid>
<Grid Margin="0,4,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="120"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBlock Text="Load profile" VerticalAlignment="Center"/>
<CheckBox Grid.Column="1" IsChecked="{Binding RunAsLoadProfile}" VerticalAlignment="Center"
Content="Load the user's HKCU + AppData (slower; only needed if the script reads user-scoped state)"/>
</Grid>
</StackPanel>
</StackPanel>
</GroupBox>
<GroupBox Header="Response" Padding="6" Margin="0,8,0,0">
<Grid>
<Grid.ColumnDefinitions>
@@ -19,15 +19,15 @@ public partial class EndpointEditor : Window
Close();
}
private void OnBearerPasswordChanged(object sender, RoutedEventArgs e)
private void OnCopyBearer(object sender, RoutedEventArgs e)
{
if (DataContext is EndpointEditorViewModel vm && sender is PasswordBox box)
vm.BearerSecretInput = box.Password;
if (DataContext is EndpointEditorViewModel vm && !string.IsNullOrEmpty(vm.BearerSecret))
try { Clipboard.SetText(vm.BearerSecret); } catch { /* clipboard busy — silent */ }
}
private void OnHmacPasswordChanged(object sender, RoutedEventArgs e)
private void OnCopyHmac(object sender, RoutedEventArgs e)
{
if (DataContext is EndpointEditorViewModel vm && sender is PasswordBox box)
vm.HmacSecretInput = box.Password;
if (DataContext is EndpointEditorViewModel vm && !string.IsNullOrEmpty(vm.HmacSecret))
try { Clipboard.SetText(vm.HmacSecret); } catch { /* clipboard busy — silent */ }
}
}
@@ -5,7 +5,7 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="clr-namespace:WebhookServer.Gui.ViewModels"
mc:Ignorable="d"
Title="Server Settings" Height="500" Width="540"
Title="Server Settings" Height="720" Width="600"
WindowStartupLocation="CenterOwner"
d:DataContext="{d:DesignInstance Type=vm:ServerSettingsViewModel}">
<DockPanel Margin="12">
@@ -14,6 +14,7 @@
<Button Content="Cancel" Width="80" IsCancel="True"/>
</StackPanel>
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel>
<GroupBox Header="HTTP" Padding="6">
<Grid>
@@ -26,6 +27,41 @@
</Grid>
</GroupBox>
<GroupBox Header="Network" Padding="6" Margin="0,8,0,0">
<StackPanel>
<CheckBox Content="Listen on all interfaces (0.0.0.0 + ::)" IsChecked="{Binding ListenAllInterfaces}"/>
<TextBlock Foreground="Gray" FontStyle="Italic" FontSize="11" Margin="20,2,0,0"
Text="Uncheck to bind only to the addresses you select below."/>
<ItemsControl ItemsSource="{Binding Addresses}" Margin="20,4,0,0"
IsEnabled="{Binding ListenAllInterfaces, Converter={StaticResource InvertBool}}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<CheckBox IsChecked="{Binding IsBound}" Margin="0,1,0,1">
<StackPanel Orientation="Horizontal">
<TextBlock Text="{Binding Address}" FontFamily="Consolas" Width="220"/>
<TextBlock Text="{Binding Label}" Foreground="Gray" Margin="8,0,0,0"/>
</StackPanel>
</CheckBox>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Grid Margin="0,8,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="160"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBlock Text="Display URL host" VerticalAlignment="Center"/>
<ComboBox Grid.Column="1" IsEditable="True"
ItemsSource="{Binding DisplayHostChoices}"
Text="{Binding DisplayHost, UpdateSourceTrigger=PropertyChanged}"
FontFamily="Consolas"/>
</Grid>
<TextBlock Foreground="Gray" FontStyle="Italic" FontSize="11" Margin="160,2,0,0"
Text="Used in the URL column and Copy URL button. Cosmetic only - doesn't affect what the server actually accepts."/>
</StackPanel>
</GroupBox>
<GroupBox Header="HTTPS" Padding="6" Margin="0,8,0,0">
<StackPanel>
<CheckBox Content="Enabled" IsChecked="{Binding HttpsEnabled}"/>
@@ -58,7 +94,7 @@
<TextBox Grid.Row="2" Grid.Column="1" Text="{Binding PfxPath, UpdateSourceTrigger=PropertyChanged}" Margin="0,4,0,0"/>
<TextBlock Grid.Row="3" Text="PFX password" VerticalAlignment="Center" Margin="0,4,0,0"/>
<PasswordBox Grid.Row="3" Grid.Column="1" PasswordChanged="OnPfxPasswordChanged" Margin="0,4,0,0"/>
<TextBox Grid.Row="3" Grid.Column="1" Text="{Binding PfxPassword, UpdateSourceTrigger=PropertyChanged}" Margin="0,4,0,0" FontFamily="Consolas"/>
<TextBlock Grid.Row="4" Text="Thumbprint" VerticalAlignment="Center" Margin="0,4,0,0"/>
<TextBox Grid.Row="4" Grid.Column="1" Text="{Binding Thumbprint, UpdateSourceTrigger=PropertyChanged}" Margin="0,4,0,0"/>
@@ -70,5 +106,6 @@
<TextBox Text="{Binding TrustedProxiesText, UpdateSourceTrigger=LostFocus}" AcceptsReturn="True" MinHeight="80" TextWrapping="Wrap" VerticalScrollBarVisibility="Auto"/>
</GroupBox>
</StackPanel>
</ScrollViewer>
</DockPanel>
</Window>
@@ -19,12 +19,6 @@ public partial class ServerSettings : Window
Close();
}
private void OnPfxPasswordChanged(object sender, RoutedEventArgs e)
{
if (DataContext is ServerSettingsViewModel vm && sender is PasswordBox box)
vm.PfxPasswordInput = box.Password;
}
private void OnModeChecked(object sender, RoutedEventArgs e)
{
if (DataContext is ServerSettingsViewModel vm && sender is RadioButton rb && rb.Tag is string tag)
+16 -7
View File
@@ -104,6 +104,7 @@ internal sealed class AdminPipeServer : BackgroundService
Running = true,
HttpPort = snap.HttpPort,
HttpsPort = snap.HttpsBinding?.Port,
DisplayHost = snap.DisplayHost,
StartedAt = _state.StartedAt,
EndpointCount = snap.Endpoints.Count,
});
@@ -120,6 +121,7 @@ internal sealed class AdminPipeServer : BackgroundService
var incoming = DeserializeData<ServerConfig>(request) ?? throw new ArgumentException("missing config payload");
MergeWithExistingSecrets(incoming, _state.Snapshot());
await _state.ReplaceAsync(incoming, ct).ConfigureAwait(false);
_logger.LogInformation("Server config replaced ({Count} endpoints)", incoming.Endpoints.Count);
return AdminResponse.Success(SafeSnapshotForWire(_state.Snapshot()));
}
@@ -135,6 +137,7 @@ internal sealed class AdminPipeServer : BackgroundService
return AdminResponse.Failure($"slug '{ep.Slug}' already exists");
next.Endpoints.Add(ep);
await _state.ReplaceAsync(next, ct).ConfigureAwait(false);
_logger.LogInformation("Endpoint created: {Slug} ({Id})", ep.Slug, ep.Id);
return AdminResponse.Success(ep);
}
@@ -147,6 +150,7 @@ internal sealed class AdminPipeServer : BackgroundService
MergeEndpointSecrets(ep, next.Endpoints[idx]);
next.Endpoints[idx] = ep;
await _state.ReplaceAsync(next, ct).ConfigureAwait(false);
_logger.LogInformation("Endpoint updated: {Slug} ({Id})", ep.Slug, ep.Id);
return AdminResponse.Success(ep);
}
@@ -157,6 +161,7 @@ internal sealed class AdminPipeServer : BackgroundService
var removed = next.Endpoints.RemoveAll(e => e.Id == args.Id);
if (removed == 0) return AdminResponse.Failure("endpoint not found");
await _state.ReplaceAsync(next, ct).ConfigureAwait(false);
_logger.LogInformation("Endpoint deleted: {Id}", args.Id);
return AdminResponse.Success();
}
@@ -167,8 +172,10 @@ internal sealed class AdminPipeServer : BackgroundService
var next = CloneSnapshotForEdit();
var ep = next.Endpoints.FirstOrDefault(e => e.Id == args.Id);
if (ep is null) return AdminResponse.Failure("endpoint not found");
ep.Enabled = request.Op == AdminOps.EnableEndpoint;
var newState = request.Op == AdminOps.EnableEndpoint;
ep.Enabled = newState;
await _state.ReplaceAsync(next, ct).ConfigureAwait(false);
_logger.LogInformation("Endpoint {Slug} {State}", ep.Slug, newState ? "enabled" : "disabled");
return AdminResponse.Success(ep);
}
@@ -178,6 +185,8 @@ internal sealed class AdminPipeServer : BackgroundService
var next = CloneSnapshotForEdit();
next.HttpsBinding = binding;
await _state.ReplaceAsync(next, ct).ConfigureAwait(false);
_logger.LogInformation("HTTPS binding {Action}",
binding is null || binding.Kind == HttpsBindingKind.None ? "cleared" : $"set ({binding.Kind} on port {binding.Port})");
return AdminResponse.Success();
}
@@ -214,16 +223,15 @@ internal sealed class AdminPipeServer : BackgroundService
}
/// <summary>
/// Strip plaintext secrets from a snapshot before sending to the GUI. Encrypted
/// blobs are useless to the GUI but harmless; plaintext must never leak.
/// Deep-clone the snapshot for the GUI. Plaintext secrets ARE included on the
/// wire — the admin pipe is ACL'd to SYSTEM and Administrators, so anyone able
/// to read the wire already has full local privilege. Letting the GUI display
/// secrets means an admin can recover a lost token without resetting it.
/// </summary>
private static ServerConfig SafeSnapshotForWire(ServerConfig snap)
{
// Deep clone via JSON, then null out plaintext on the clone.
var json = JsonSerializer.Serialize(snap, ConfigJson.Compact);
var clone = JsonSerializer.Deserialize<ServerConfig>(json, ConfigJson.Compact)!;
ConfigStore.ClearPlaintexts(clone);
return clone;
return JsonSerializer.Deserialize<ServerConfig>(json, ConfigJson.Compact)!;
}
/// <summary>
@@ -248,6 +256,7 @@ internal sealed class AdminPipeServer : BackgroundService
{
if (incoming.Bearer is { } a) MergeProtected(a.Secret, prior.Bearer?.Secret);
if (incoming.Hmac is { } h) MergeProtected(h.Secret, prior.Hmac?.Secret);
if (incoming.RunAs is { Password: { } runAsPwd }) MergeProtected(runAsPwd, prior.RunAs?.Password);
if (incoming.Callback is { } cb)
{
if (cb.Bearer is { } cba) MergeProtected(cba.Secret, prior.Callback?.Bearer?.Secret);
+26 -2
View File
@@ -36,7 +36,7 @@ try
builder.WebHost.ConfigureKestrel(opts =>
{
opts.ListenAnyIP(initialConfig.HttpPort);
ConfigureHttp(opts, initialConfig);
ConfigureHttps(opts, initialConfig.HttpsBinding);
});
@@ -62,7 +62,10 @@ try
lifetime.StopApplication();
};
app.MapPost("/hook/{slug}", async (string slug, HttpContext http) =>
// Accept POST (the standard webhook verb) and GET (so a browser can smoke-test
// hooks without curl). GET requests will have an empty body, which the executor
// and arg-template renderer handle as if the body were empty JSON.
app.MapMethods("/hook/{slug}", new[] { "GET", "POST" }, async (string slug, HttpContext http) =>
{
var router = http.RequestServices.GetRequiredService<WebhookRouter>();
await router.HandleAsync(http, slug);
@@ -70,6 +73,9 @@ try
app.MapGet("/healthz", () => Results.Ok(new { ok = true }));
// Stop browsers from logging 404s for favicon.ico every time they hit a hook.
app.MapGet("/favicon.ico", () => Results.StatusCode(StatusCodes.Status204NoContent));
await app.RunAsync().ConfigureAwait(false);
}
catch (Exception ex)
@@ -81,6 +87,24 @@ finally
Log.CloseAndFlush();
}
static void ConfigureHttp(Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerOptions opts, ServerConfig cfg)
{
if (cfg.BindAddresses is { Count: > 0 } binds)
{
foreach (var entry in binds)
{
if (System.Net.IPAddress.TryParse(entry, out var ip))
opts.Listen(ip, cfg.HttpPort);
else
Log.Warning("Skipping invalid bind address {Entry}", entry);
}
}
else
{
opts.ListenAnyIP(cfg.HttpPort);
}
}
static void ConfigureHttps(Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerOptions opts, HttpsBinding? binding)
{
if (binding is null || binding.Kind == HttpsBindingKind.None) return;
@@ -102,6 +102,7 @@ public sealed class ServiceState
private static bool HasListenerSettingsChanged(ServerConfig oldCfg, ServerConfig newCfg)
{
if (oldCfg.HttpPort != newCfg.HttpPort) return true;
if (!oldCfg.BindAddresses.SequenceEqual(newCfg.BindAddresses, StringComparer.OrdinalIgnoreCase)) return true;
var a = oldCfg.HttpsBinding;
var b = newCfg.HttpsBinding;
if ((a is null) != (b is null)) return true;
@@ -110,6 +111,7 @@ public sealed class ServiceState
if (a.Kind != b.Kind || a.Port != b.Port || a.PfxPath != b.PfxPath || a.Thumbprint != b.Thumbprint)
return true;
}
// DisplayHost is cosmetic; don't restart for it.
return false;
}
}
@@ -126,6 +126,7 @@ public sealed class WebhookRouter
}
var result = await RunAsync(endpoint, ctx, http.RequestAborted).ConfigureAwait(false);
LogResult(endpoint, ctx, result);
DispatchCallback(endpoint, ctx, result);
if (result.LaunchError is not null)
@@ -157,6 +158,7 @@ public sealed class WebhookRouter
try
{
var result = await RunAsync(endpoint, ctx, ct).ConfigureAwait(false);
LogResult(endpoint, ctx, result);
DispatchCallback(endpoint, ctx, result);
}
catch (Exception ex)
@@ -165,6 +167,47 @@ public sealed class WebhookRouter
}
}
private void LogResult(EndpointConfig endpoint, ExecCtx ctx, ExecutionResult result)
{
if (result.LaunchError is not null)
{
_logger.LogWarning("Run {RunId} {Slug} failed to launch: {Error}",
ctx.RunId, ctx.Slug, result.LaunchError);
return;
}
if (result.TimedOut)
{
_logger.LogWarning("Run {RunId} {Slug} timed out after {Sec}s; process killed",
ctx.RunId, ctx.Slug, endpoint.TimeoutSeconds);
return;
}
var stdout = TruncateForLog(result.Stdout, 512);
var stderr = TruncateForLog(result.Stderr, 512);
if (result.Succeeded)
{
_logger.LogInformation(
"Run {RunId} {Slug} ok exit={Exit} dur={Ms}ms stdout={Stdout}{StderrPart}",
ctx.RunId, ctx.Slug, result.ExitCode, (long)result.Duration.TotalMilliseconds,
stdout, string.IsNullOrEmpty(stderr) ? "" : $" stderr={stderr}");
}
else
{
_logger.LogWarning(
"Run {RunId} {Slug} non-zero exit={Exit} dur={Ms}ms stdout={Stdout} stderr={Stderr}",
ctx.RunId, ctx.Slug, result.ExitCode, (long)result.Duration.TotalMilliseconds,
stdout, stderr);
}
}
private static string TruncateForLog(string s, int max)
{
if (string.IsNullOrEmpty(s)) return "(empty)";
var trimmed = s.Trim();
if (trimmed.Length <= max) return trimmed;
return trimmed.Substring(0, max) + $"... [+{trimmed.Length - max} chars]";
}
private async Task<ExecutionResult> RunAsync(EndpointConfig endpoint, ExecCtx ctx, CancellationToken ct)
{
if (endpoint.Serialize)