23 Commits

Author SHA1 Message Date
justin 16ce906044 Installer: synchronous service stop + kill stray GUI/Service processes
Release / build-installer (push) Has been cancelled
The previous sc.exe stop is fire-and-forget; on slower machines the
file-copy step started before the service had actually released its
binaries, leaving the upgrade in a broken state. Switch to net.exe
stop which blocks until the service reports STOPPED.

Also taskkill any running WebhookServer.Gui.exe (the user might have
left the tray running) and any orphan WebhookServer.Service.exe (from
deploy.ps1 dev runs) so all copies of the binaries are unlocked
before [Files] runs.

Pre-flight ServiceExists() check via sc query so the installer only
calls "net stop" when there is actually a service to stop, rather
than relying on net's error code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v0.1.1
2026-05-08 10:18:01 -04:00
justin a24d49f463 Rename "Backups" menu item to "Config Checkpoints"
User-facing copy only; internal API names (Backups collection,
BackupEntry, list-backups op, etc.) stay the same to avoid churn
through the wire protocol and existing on-disk files. The new
phrasing makes the auto-snapshot-before-save model more discoverable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 10:16:49 -04:00
justin e7e533d8c6 v0.1.1: GUI auto-elevates, installer stops service before file copy
Two fixes for the v0.1.0 install experience:

1. Embed app.manifest with requestedExecutionLevel=requireAdministrator
   so the GUI always elevates. The named pipe is ACL'd to SYSTEM and
   the Administrators group, but UAC token splitting puts Admins in
   deny-only on the standard token, so launching the GUI from the
   Start Menu fails to connect with "Access is denied". The manifest
   forces UAC to elevate, surfaces the shield icon on the shortcut,
   and matches the reality that the GUI cannot function without
   admin rights.

2. Add a [Code] PrepareToInstall hook to webhook-server.iss that runs
   `sc stop WebhookServer` before file copy. Upgrade installs were
   failing on locked binaries because the running service held the
   exes open. sc returns non-zero on fresh installs (no service yet)
   which we ignore.

Bumps Version to 0.1.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 10:15:26 -04:00
justin 93a9c327e0 Phase 4: backups + import/export config
Release / build-installer (push) Has been cancelled
CI / build (pull_request) Has been cancelled
ConfigStore.SaveAsync now snapshots the previous config to
%ProgramData%\WebhookServer\backups\config-<timestamp>.json before
overwriting, retaining the last 30. Failures are silent so a
backup-write hiccup never blocks an actual save.

Three new admin pipe ops:
- list-backups: returns newest 50 entries with timestamps and sizes
- restore-backup: takes a fileName, refuses path-traversal chars,
  loads the named backup over the live config (which itself triggers
  a fresh backup of the current state via the SaveAsync hook)
- import-config: replaces the current config with a GUI-supplied
  ServerConfig, merging encrypted secrets where the GUI didn't supply
  new plaintext

GUI File menu items are wired:
- Import config: file picker -> ImportConfigAsync
- Export config: SaveFileDialog writes the current config as JSON
- Backups: dynamic submenu auto-refreshed when opened, listing
  backups with timestamp + size; click to confirm-and-restore

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v0.1.0
2026-05-08 09:55:03 -04:00
justin 9e6abeef74 Phase 6+7: Inno Setup installer + GitHub Actions release pipeline
CI / build (pull_request) Has been cancelled
installer/webhook-server.iss is an Inno Setup 6 script that:
- Installs to %ProgramFiles%\WebhookServer
- Creates Start Menu folder + GUI shortcut (and optional desktop icon)
- Runs install-service.ps1 post-install to register the Windows Service
- Runs uninstall-service.ps1 pre-uninstall to remove it
- Bundles the webhook-server icon for the installer / uninstaller

scripts/build-installer.ps1 is the local build helper: publishes both
projects, finds ISCC.exe (PATH or standard install path), compiles the
installer with the version pulled from Directory.Build.props, drops the
output in dist/.

.github/workflows/ci.yml runs build + test on every push/PR to main.
.github/workflows/release.yml triggers on v* tags (or manual dispatch),
runs tests, installs Inno Setup via choco, builds the installer, and
attaches the .exe to a GitHub Release. Pre-1.0 versions are flagged
prerelease automatically.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 09:52:37 -04:00
justin 9525ee358e Phase 5: tray icon with minimize-to-tray and context menu
GUI csproj enables UseWindowsForms (NotifyIcon lives in WinForms even
in .NET 8). New Services/TrayIcon.cs wraps NotifyIcon with a context
menu (Open / Restart service / Exit) and the embedded webhook-server
icon. MainWindow creates the TrayIcon, hides itself on minimize and
restores on tray double-click.

Adds GlobalUsings.cs to alias the WPF defaults for types that exist
in both WPF and WinForms (Application, MessageBox, TextBox, Binding,
etc.) so existing code keeps compiling without per-file changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 09:51:00 -04:00
justin f3bca1e8ff Phase 3: app icon (multi-resolution ICO + master PNG)
scripts/generate-icons.ps1 renders the icon programmatically with
System.Drawing - rounded teal square (#0E7C66) with a stylized white
hook glyph - at 16/24/32/48/64/128/256 px and assembles a proper
multi-resolution Microsoft ICO. The PNG and ICO outputs land in
resources/. The script is the source of truth; re-run after editing
the design.

GUI csproj uses ApplicationIcon for the EXE icon and embeds the .ico
+ .png as Resources so MainWindow and AboutDialog can use them via
WPF's resource URI scheme.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 09:48:33 -04:00
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
justin ebac2c7c04 Fix PowerShell -Command argv binding so positional args + stdin work
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>
2026-05-08 08:12:48 -04:00
justin 8ecfe84540 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) <noreply@anthropic.com>
2026-05-07 22:04:52 -04:00
justin 2f61b342af Document service account choices for AD-aware hooks
Add a Service account section to PLAN.md and README.md covering
LocalSystem, domain user, and gMSA install paths so users running AD
PowerShell scripts know which identity to pick. Drop the stale
"outbound webhook delivery" out-of-scope bullet now that callbacks are
in v1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 21:32:07 -04:00
justin 5897f4632a Mention outbound callbacks in README highlights
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 00:34:03 -04:00
justin 920c3d8916 Add outbound callback design to plan
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>
2026-05-07 00:32:20 -04:00
justin 2a4b1b3adb Initial plan and README for Windows webhook server
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>
2026-05-07 00:27:50 -04:00