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>
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>
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>
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>
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>
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>
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>
PowerShell's -Command treats trailing argv entries as part of the
command-line text rather than as $args, so a hook with an inline
command and an arg template raised a parser error. Wrap inline commands
in a scriptblock with @args splat, and pipe $input into the block so
{{body.*}} arg templates AND stdin JSON both reach the script. Verified
end-to-end against ping, bearer (good/bad/missing), HMAC (good/bad/missing),
IP allowlist deny, async 202, and stdin+template combined.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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) <noreply@anthropic.com>
Add a Service account section to PLAN.md and README.md covering
LocalSystem, domain user, and gMSA install paths so users running AD
PowerShell scripts know which identity to pick. Drop the stale
"outbound webhook delivery" out-of-scope bullet now that callbacks are
in v1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per-endpoint optional callback URL: service POSTs run result after async
runs (and optionally sync). Reuses inbound HMAC code path for outbound
signing. No caller-supplied URLs (SSRF risk). Bounded queue, exponential
backoff with jitter, configurable retries.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Empty project scaffolded with the approved implementation plan,
README overview, and a .NET-appropriate .gitignore. Implementation
will follow on a Windows machine.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>