17 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>
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>
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
43 changed files with 2308 additions and 112 deletions
+27
View File
@@ -0,0 +1,27 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Restore
run: dotnet restore WebhookServer.sln
- name: Build
run: dotnet build WebhookServer.sln -c Release --no-restore
- name: Test
run: dotnet test WebhookServer.sln -c Release --no-build --verbosity normal
+71
View File
@@ -0,0 +1,71 @@
name: Release
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
version:
description: 'Version to build (e.g. 0.1.0). Defaults to Directory.Build.props.'
required: false
jobs:
build-installer:
runs-on: windows-latest
permissions:
contents: write # needed to create releases / upload assets
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Resolve version
id: ver
shell: pwsh
run: |
if ('${{ github.event_name }}' -eq 'push') {
$v = '${{ github.ref_name }}'.TrimStart('v')
} elseif ('${{ inputs.version }}') {
$v = '${{ inputs.version }}'
} else {
[xml]$p = Get-Content Directory.Build.props
$v = $p.Project.PropertyGroup.Version
}
"version=$v" | Out-File $env:GITHUB_OUTPUT -Append
Write-Host "Building version $v"
- name: Restore + test
run: |
dotnet restore WebhookServer.sln
dotnet test WebhookServer.sln -c Release
- name: Install Inno Setup
shell: pwsh
run: |
choco install innosetup --no-progress -y
Write-Host "ISCC at: $((Get-Command iscc).Path)"
- name: Build installer
shell: pwsh
run: ./scripts/build-installer.ps1 -VersionOverride ${{ steps.ver.outputs.version }}
- name: Upload installer artifact
uses: actions/upload-artifact@v4
with:
name: WebhookServer-Setup-${{ steps.ver.outputs.version }}
path: dist/WebhookServer-Setup-*.exe
- name: Create GitHub Release
if: startsWith(github.ref, 'refs/tags/v')
uses: softprops/action-gh-release@v2
with:
name: Webhook Server ${{ steps.ver.outputs.version }}
tag_name: ${{ github.ref_name }}
draft: false
prerelease: ${{ startsWith(steps.ver.outputs.version, '0.') }}
files: dist/WebhookServer-Setup-*.exe
generate_release_notes: true
+14
View File
@@ -0,0 +1,14 @@
<Project>
<PropertyGroup>
<Version>0.1.1</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>
+117
View File
@@ -0,0 +1,117 @@
; Inno Setup script for Webhook Server.
;
; Build: iscc /DAppVersion=0.1.0 webhook-server.iss
; Output: ..\dist\WebhookServer-Setup-{AppVersion}.exe
;
; The installer copies published binaries to {pf}\WebhookServer, installs the
; Windows Service via install-service.ps1 post-install, and removes the service
; via uninstall-service.ps1 pre-uninstall. Start Menu gets a single GUI shortcut.
#ifndef AppVersion
#define AppVersion "0.1.0"
#endif
#define AppName "Webhook Server"
#define AppPublisher "Justin Paul"
#define AppURL "https://jpaul.me"
#define AppExeName "WebhookServer.Gui.exe"
#define ServiceExeName "WebhookServer.Service.exe"
#define ServiceName "WebhookServer"
#define RepoRoot "..\"
[Setup]
AppId={{6E3B3C1A-9C20-4F50-B6A8-2B6D6D7E2F11}
AppName={#AppName}
AppVersion={#AppVersion}
AppPublisher={#AppPublisher}
AppPublisherURL={#AppURL}
AppSupportURL=https://github.com/recklessop/webhook-server
AppUpdatesURL=https://github.com/recklessop/webhook-server/releases
DefaultDirName={autopf}\WebhookServer
DefaultGroupName={#AppName}
DisableProgramGroupPage=yes
OutputBaseFilename=WebhookServer-Setup-{#AppVersion}
OutputDir={#RepoRoot}dist
SetupIconFile={#RepoRoot}resources\webhook-server.ico
UninstallDisplayIcon={app}\{#AppExeName}
PrivilegesRequired=admin
ArchitecturesAllowed=x64compatible
ArchitecturesInstallIn64BitMode=x64compatible
Compression=lzma2/max
SolidCompression=yes
WizardStyle=modern
VersionInfoVersion={#AppVersion}.0
VersionInfoCompany={#AppPublisher}
VersionInfoProductName={#AppName}
[Languages]
Name: "english"; MessagesFile: "compiler:Default.isl"
[Tasks]
Name: "desktopicon"; Description: "Create a &desktop shortcut"; GroupDescription: "Additional shortcuts:"; Flags: unchecked
[Files]
Source: "{#RepoRoot}publish\service\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
Source: "{#RepoRoot}publish\gui\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
Source: "{#RepoRoot}scripts\install-service.ps1"; DestDir: "{app}\scripts"; Flags: ignoreversion
Source: "{#RepoRoot}scripts\uninstall-service.ps1"; DestDir: "{app}\scripts"; Flags: ignoreversion
Source: "{#RepoRoot}README.md"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#RepoRoot}resources\webhook-server.ico"; DestDir: "{app}"; Flags: ignoreversion
[Icons]
Name: "{group}\{#AppName}"; Filename: "{app}\{#AppExeName}"; IconFilename: "{app}\webhook-server.ico"
Name: "{group}\Uninstall {#AppName}"; Filename: "{uninstallexe}"
Name: "{commondesktop}\{#AppName}"; Filename: "{app}\{#AppExeName}"; IconFilename: "{app}\webhook-server.ico"; Tasks: desktopicon
[Run]
Filename: "powershell.exe"; \
Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{app}\scripts\install-service.ps1"" -BinaryPath ""{app}\{#ServiceExeName}"""; \
StatusMsg: "Installing Windows Service..."; \
Flags: runhidden
Filename: "{app}\{#AppExeName}"; \
Description: "Launch {#AppName}"; \
Flags: postinstall nowait skipifsilent
[UninstallRun]
Filename: "powershell.exe"; \
Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{app}\scripts\uninstall-service.ps1"""; \
Flags: runhidden; \
RunOnceId: "RemoveWebhookService"
[Code]
function ServiceExists(): Boolean;
var
ResultCode: Integer;
begin
// sc.exe query returns 0 when the service exists, 1060 when it does not.
Exec(ExpandConstant('{sys}\sc.exe'), 'query WebhookServer', '', SW_HIDE,
ewWaitUntilTerminated, ResultCode);
Result := (ResultCode = 0);
end;
function PrepareToInstall(var NeedsRestart: Boolean): String;
var
ResultCode: Integer;
begin
Result := '';
// 1. If the service exists, stop it so its binaries are unlocked before file
// copy. net stop is synchronous (blocks until the service is actually
// stopped), unlike sc stop which is fire-and-forget. Non-zero exit -
// already stopped, missing, dependency error - we ignore; the file copy
// will fail loudly if the binaries are still locked.
if ServiceExists() then
begin
WizardForm.PreparingLabel.Caption := 'Stopping the WebhookServer service...';
Exec(ExpandConstant('{sys}\net.exe'), 'stop WebhookServer', '', SW_HIDE,
ewWaitUntilTerminated, ResultCode);
end;
// 2. Kill any running GUI / tray instances so their binaries are unlocked too.
// /f forces termination, /im matches by image name, "*" wildcard would be
// risky so we name them explicitly.
Exec(ExpandConstant('{sys}\taskkill.exe'), '/f /im WebhookServer.Gui.exe',
'', SW_HIDE, ewWaitUntilTerminated, ResultCode);
Exec(ExpandConstant('{sys}\taskkill.exe'), '/f /im WebhookServer.Service.exe',
'', SW_HIDE, ewWaitUntilTerminated, ResultCode);
end;
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

+68
View File
@@ -0,0 +1,68 @@
<#
.SYNOPSIS
End-to-end installer build: publish service + GUI, then run Inno Setup
to produce dist/WebhookServer-Setup-{version}.exe.
.DESCRIPTION
Reads the version from Directory.Build.props. Requires Inno Setup 6 (ISCC.exe)
on PATH or in the standard install location. CI runs this same script after
setup-dotnet + winget install Inno Setup.
#>
[CmdletBinding()]
param(
[string]$Configuration = 'Release',
[string]$VersionOverride
)
$ErrorActionPreference = 'Stop'
$repoRoot = Split-Path -Parent $PSScriptRoot
function Get-RepoVersion {
$propsPath = Join-Path $repoRoot 'Directory.Build.props'
[xml]$props = Get-Content $propsPath
return $props.Project.PropertyGroup.Version
}
function Find-InnoCompiler {
$candidates = @(
'ISCC.exe', # on PATH
'C:\Program Files (x86)\Inno Setup 6\ISCC.exe',
'C:\Program Files\Inno Setup 6\ISCC.exe'
)
foreach ($c in $candidates) {
$cmd = Get-Command $c -ErrorAction SilentlyContinue
if ($cmd) { return $cmd.Path }
if (Test-Path $c) { return $c }
}
throw "Inno Setup compiler not found. Install with: winget install JRSoftware.InnoSetup"
}
$version = if ($VersionOverride) { $VersionOverride } else { Get-RepoVersion }
Write-Host "Building Webhook Server installer v$version" -ForegroundColor Cyan
# 1. Publish both projects.
$publishSvc = Join-Path $repoRoot 'publish\service'
$publishGui = Join-Path $repoRoot 'publish\gui'
Remove-Item -Recurse -Force $publishSvc, $publishGui -ErrorAction SilentlyContinue
& dotnet publish (Join-Path $repoRoot 'src\WebhookServer.Service\WebhookServer.Service.csproj') `
-c $Configuration -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 $Configuration -r win-x64 --self-contained false -o $publishGui | Out-Host
if ($LASTEXITCODE -ne 0) { throw 'GUI publish failed' }
# 2. Compile installer.
$iscc = Find-InnoCompiler
$iss = Join-Path $repoRoot 'installer\webhook-server.iss'
$dist = Join-Path $repoRoot 'dist'
New-Item -ItemType Directory -Path $dist -Force | Out-Null
Write-Host "Compiling installer with $iscc"
& $iscc "/DAppVersion=$version" $iss
if ($LASTEXITCODE -ne 0) { throw 'Inno Setup compile failed' }
$out = Get-Item (Join-Path $dist "WebhookServer-Setup-$version.exe")
Write-Host ""
Write-Host ("Built: {0} ({1:n0} bytes)" -f $out.FullName, $out.Length) -ForegroundColor Green
+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')"
+138
View File
@@ -0,0 +1,138 @@
<#
.SYNOPSIS
Generates webhook-server.ico (multi-resolution) and webhook-server.png from
a programmatic design. Re-run after changing Draw-Icon to refresh assets.
.DESCRIPTION
Renders the icon at 16/24/32/48/64/128/256 px using System.Drawing, then
assembles a Microsoft-format ICO file with each size embedded as PNG. No
external tools required.
Design: a rounded-square teal background (#0E7C66) with a stylized white
hook shape (a "J"-like curve with an arrow tip).
#>
[CmdletBinding()]
param(
[string]$OutputDir = (Join-Path $PSScriptRoot '..\resources')
)
$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName System.Drawing
New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null
function New-IconBitmap([int]$size) {
$bmp = New-Object System.Drawing.Bitmap $size, $size, ([System.Drawing.Imaging.PixelFormat]::Format32bppArgb)
$g = [System.Drawing.Graphics]::FromImage($bmp)
$g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
$g.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
$g.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality
# Background: rounded square in brand teal.
$bgColor = [System.Drawing.Color]::FromArgb(0xFF, 0x0E, 0x7C, 0x66)
$bgBrush = New-Object System.Drawing.SolidBrush $bgColor
$radius = [int]($size * 0.22)
$rect = New-Object System.Drawing.RectangleF 0, 0, $size, $size
$path = New-Object System.Drawing.Drawing2D.GraphicsPath
$d = $radius * 2
$path.AddArc($rect.X, $rect.Y, $d, $d, 180, 90)
$path.AddArc($rect.Right - $d, $rect.Y, $d, $d, 270, 90)
$path.AddArc($rect.Right - $d, $rect.Bottom - $d, $d, $d, 0, 90)
$path.AddArc($rect.X, $rect.Bottom - $d, $d, $d, 90, 90)
$path.CloseFigure()
$g.FillPath($bgBrush, $path)
# Foreground: white hook shape - a thick curved stroke shaped like a "J"
# tipped with an arrowhead, sized relative to the canvas.
$fgColor = [System.Drawing.Color]::White
$stroke = [Math]::Max(2, [int]($size * 0.12))
$pen = New-Object System.Drawing.Pen $fgColor, $stroke
$pen.StartCap = [System.Drawing.Drawing2D.LineCap]::Round
$pen.EndCap = [System.Drawing.Drawing2D.LineCap]::Round
$pen.LineJoin = [System.Drawing.Drawing2D.LineJoin]::Round
# Hook curve: vertical down-stroke on the right, then a half-circle arc
# curling to the left and ending in a small filled dot for the hook tip.
$cx = [single]($size * 0.62)
$top = [single]($size * 0.22)
$bottom = [single]($size * 0.58)
$arcD = [single]($size * 0.34) # arc diameter
$arcLeft = [single]($cx - $arcD) # left edge of arc circle
# Vertical stroke from (cx, top) to (cx, bottom).
$g.DrawLine($pen, $cx, $top, $cx, $bottom)
# Half-circle arc beneath: starts at (cx, bottom), curls to (cx - arcD, bottom).
$arcRect = New-Object System.Drawing.RectangleF $arcLeft, ([single]($bottom - $arcD / 2)), $arcD, $arcD
$g.DrawArc($pen, $arcRect, 0, 180)
# Filled circle at the tip end of the arc.
$tipR = [single]($stroke * 0.6)
$tipX = $arcLeft
$tipY = [single]($bottom)
$brushFg = New-Object System.Drawing.SolidBrush $fgColor
$g.FillEllipse($brushFg, [single]($tipX - $tipR), [single]($tipY - $tipR), [single]($tipR * 2), [single]($tipR * 2))
$brushFg.Dispose(); $pen.Dispose(); $bgBrush.Dispose(); $path.Dispose()
$g.Dispose()
return $bmp
}
# Generate each size as PNG bytes. Hashtable keys are prefixed with "s" because
# PowerShell hashtable lookups by integer key behave inconsistently with PSObject
# wrapping; string keys round-trip cleanly.
$sizes = @(16, 24, 32, 48, 64, 128, 256)
$pngs = @{}
foreach ($s in $sizes) {
$bmp = New-IconBitmap $s
$ms = New-Object System.IO.MemoryStream
$bmp.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)
$pngs["s$s"] = $ms.ToArray()
$ms.Dispose()
$bmp.Dispose()
}
# Save the master 256 PNG separately for places that need a transparent PNG.
$pngPath = Join-Path $OutputDir 'webhook-server.png'
[System.IO.File]::WriteAllBytes($pngPath, [byte[]]$pngs['s256'])
# Assemble multi-resolution ICO.
$icoPath = Join-Path $OutputDir 'webhook-server.ico'
$ms = New-Object System.IO.MemoryStream
$bw = New-Object System.IO.BinaryWriter $ms
try {
$count = $sizes.Count
$bw.Write([UInt16]0) # idReserved
$bw.Write([UInt16]1) # idType: 1 = ICO
$bw.Write([UInt16]$count) # idCount
# Directory entries (16 bytes each).
$offset = 6 + 16 * $count
foreach ($s in $sizes) {
$bytes = $pngs["s$s"]
$w = if ($s -ge 256) { 0 } else { $s }
$h = if ($s -ge 256) { 0 } else { $s }
$bw.Write([byte]$w) # width
$bw.Write([byte]$h) # height
$bw.Write([byte]0) # colorCount
$bw.Write([byte]0) # reserved
$bw.Write([UInt16]1) # planes
$bw.Write([UInt16]32) # bitCount
$bw.Write([UInt32]$bytes.Length)
$bw.Write([UInt32]$offset)
$offset += $bytes.Length
}
# Image data.
foreach ($s in $sizes) { $bw.Write($pngs["s$s"]) }
$bw.Flush()
[System.IO.File]::WriteAllBytes($icoPath, $ms.ToArray())
}
finally {
$bw.Dispose(); $ms.Dispose()
}
Write-Host "Wrote $icoPath ($((Get-Item $icoPath).Length) bytes)"
Write-Host "Wrote $pngPath ($((Get-Item $pngPath).Length) bytes)"
+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);
@@ -23,6 +23,21 @@ public static class AdminOps
public const string BindHttps = "bind-https";
public const string RestartListener = "restart-listener";
public const string Ping = "ping";
public const string ListBackups = "list-backups";
public const string RestoreBackup = "restore-backup";
public const string ImportConfig = "import-config";
}
public sealed class BackupEntry
{
public string FileName { get; set; } = "";
public DateTimeOffset SavedAt { get; set; }
public long SizeBytes { get; set; }
}
public sealed class RestoreBackupArgs
{
public string FileName { get; set; } = "";
}
public sealed class AdminRequest
@@ -52,6 +67,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.
@@ -38,6 +38,25 @@ public sealed class ConfigStore
var dir = System.IO.Path.GetDirectoryName(Path);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
// Snapshot the previous config (if any) into the backups folder before
// overwriting. Cheap insurance against typos in the GUI.
if (File.Exists(Path) && !string.IsNullOrEmpty(dir))
{
try
{
var backupsDir = System.IO.Path.Combine(dir, "backups");
Directory.CreateDirectory(backupsDir);
var stamp = DateTime.UtcNow.ToString("yyyyMMdd-HHmmss");
var backupPath = System.IO.Path.Combine(backupsDir, $"config-{stamp}.json");
File.Copy(Path, backupPath, overwrite: false);
PruneBackups(backupsDir, retain: 30);
}
catch
{
// Backup is best-effort; don't fail the save if it can't write.
}
}
var tmp = Path + ".tmp";
await using (var fs = File.Create(tmp))
{
@@ -49,12 +68,24 @@ public sealed class ConfigStore
File.Move(tmp, Path, overwrite: true);
}
private static void PruneBackups(string backupsDir, int retain)
{
var stale = new DirectoryInfo(backupsDir).GetFiles("config-*.json")
.OrderByDescending(f => f.Name)
.Skip(retain);
foreach (var f in stale)
{
try { f.Delete(); } catch { }
}
}
public static void ClearPlaintexts(ServerConfig config)
{
foreach (var ep in config.Endpoints)
{
ClearOne(ep.Bearer?.Secret);
ClearOne(ep.Hmac?.Secret);
ClearOne(ep.RunAs?.Password);
if (ep.Callback is { } cb)
{
ClearOne(cb.Bearer?.Secret);
@@ -76,6 +107,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 +123,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>
-8
View File
@@ -1,13 +1,5 @@
using System.Configuration;
using System.Data;
using System.Windows;
namespace WebhookServer.Gui;
/// <summary>
/// Interaction logic for App.xaml
/// </summary>
public partial class App : Application
{
}
+26 -1
View File
@@ -1,6 +1,7 @@
using System.Globalization;
using System.Windows.Data;
using System.Windows.Media;
using Brush = System.Windows.Media.Brush;
using Brushes = System.Windows.Media.Brushes;
namespace WebhookServer.Gui.Converters;
@@ -13,6 +14,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)
+15
View File
@@ -0,0 +1,15 @@
// Enabling UseWindowsForms (for the system tray NotifyIcon) brings the WinForms
// namespace into scope, which conflicts with WPF for several common type names.
// Alias the most-used types to their WPF variants project-wide so existing code
// keeps compiling. Files that genuinely need a WinForms type import it explicitly
// (System.Windows.Forms.NotifyIcon etc. in Services/TrayIcon.cs).
global using Application = System.Windows.Application;
global using MessageBox = System.Windows.MessageBox;
global using Clipboard = System.Windows.Clipboard;
global using TextBox = System.Windows.Controls.TextBox;
global using RadioButton = System.Windows.Controls.RadioButton;
global using MessageBoxButton = System.Windows.MessageBoxButton;
global using MessageBoxImage = System.Windows.MessageBoxImage;
global using MessageBoxResult = System.Windows.MessageBoxResult;
global using Binding = System.Windows.Data.Binding;
+84 -13
View File
@@ -7,7 +7,11 @@
xmlns:models="clr-namespace:WebhookServer.Core.Models;assembly=WebhookServer.Core"
mc:Ignorable="d"
Title="Webhook Server" Height="600" Width="1000"
Icon="/webhook-server.ico"
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 +23,43 @@
</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…" Command="{Binding ImportConfigCommand}"/>
<MenuItem Header="_Export config…" Command="{Binding ExportConfigCommand}"/>
<MenuItem Header="Config _Checkpoints"
ItemsSource="{Binding Backups}"
ToolTip="Snapshots taken automatically before each save. Click one to restore."
SubmenuOpened="OnBackupsSubmenuOpened">
<MenuItem.ItemContainerStyle>
<Style TargetType="MenuItem">
<Setter Property="Header">
<Setter.Value>
<MultiBinding StringFormat="{}{0:yyyy-MM-dd HH:mm:ss} ({1:n0} bytes)">
<Binding Path="SavedAt"/>
<Binding Path="SizeBytes"/>
</MultiBinding>
</Setter.Value>
</Setter>
<Setter Property="Command" Value="{Binding DataContext.RestoreBackupCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
<Setter Property="CommandParameter" Value="{Binding}"/>
</Style>
</MenuItem.ItemContainerStyle>
</MenuItem>
<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 +76,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 +105,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 +138,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>
+48 -3
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;
@@ -6,11 +8,54 @@ namespace WebhookServer.Gui;
public partial class MainWindow : Window
{
private readonly TrayIcon _tray;
private readonly MainViewModel _vm;
public MainWindow()
{
InitializeComponent();
var vm = new MainViewModel(new AdminPipeClient());
DataContext = vm;
Loaded += async (_, _) => await vm.RefreshCommand.ExecuteAsync(null);
_vm = new MainViewModel(new AdminPipeClient());
DataContext = _vm;
_tray = new TrayIcon(
resolveMainWindow: () => Application.Current.MainWindow,
restartServiceAsync: async () => await new AdminPipeClient().RestartListenerAsync());
Loaded += async (_, _) => await _vm.RefreshCommand.ExecuteAsync(null);
StateChanged += OnStateChanged;
Closed += (_, _) => _tray.Dispose();
}
private void OnStateChanged(object? sender, EventArgs e)
{
// Minimize-to-tray: hide the window when the user minimizes; restoring is
// via the tray icon's double-click or context menu.
if (WindowState == WindowState.Minimized)
{
Hide();
ShowInTaskbar = false;
}
else
{
ShowInTaskbar = true;
}
}
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);
}
private async void OnBackupsSubmenuOpened(object sender, RoutedEventArgs e)
{
if (DataContext is MainViewModel vm)
await vm.RefreshBackupsCommand.ExecuteAsync(null);
}
}
@@ -86,4 +86,18 @@ public sealed class AdminPipeClient
var lst = resp.Data.Value.GetProperty("lines").Deserialize<List<LogLine>>(AdminProtocol.JsonOptions);
return lst ?? new List<LogLine>();
}
public async Task<List<BackupEntry>> ListBackupsAsync(CancellationToken ct = default)
{
var resp = await InvokeAsync(AdminOps.ListBackups, null, ct).ConfigureAwait(false);
if (!resp.Ok || resp.Data is null) return new List<BackupEntry>();
var lst = resp.Data.Value.GetProperty("backups").Deserialize<List<BackupEntry>>(AdminProtocol.JsonOptions);
return lst ?? new List<BackupEntry>();
}
public Task<AdminResponse> RestoreBackupAsync(string fileName, CancellationToken ct = default) =>
InvokeAsync(AdminOps.RestoreBackup, new RestoreBackupArgs { FileName = fileName }, ct);
public Task<AdminResponse> ImportConfigAsync(ServerConfig config, CancellationToken ct = default) =>
InvokeAsync(AdminOps.ImportConfig, config, ct);
}
@@ -0,0 +1,86 @@
using System.Drawing;
using System.Runtime.Versioning;
using System.Windows;
using System.Windows.Forms;
namespace WebhookServer.Gui.Services;
/// <summary>
/// Minimal system tray icon using Windows Forms NotifyIcon. Owns a context menu
/// (Open / Restart service / Exit) and toggles the main window visibility on
/// double-click. Hide-to-tray on minimize is wired in MainWindow.xaml.cs.
/// </summary>
[SupportedOSPlatform("windows")]
public sealed class TrayIcon : IDisposable
{
private readonly NotifyIcon _icon;
private readonly Func<Window?> _resolveMainWindow;
private readonly Func<Task> _restartServiceAsync;
public TrayIcon(Func<Window?> resolveMainWindow, Func<Task> restartServiceAsync)
{
_resolveMainWindow = resolveMainWindow;
_restartServiceAsync = restartServiceAsync;
_icon = new NotifyIcon
{
Icon = LoadEmbeddedIcon(),
Text = "Webhook Server",
Visible = true,
};
_icon.DoubleClick += (_, _) => ShowMainWindow();
_icon.ContextMenuStrip = BuildMenu();
}
private ContextMenuStrip BuildMenu()
{
var menu = new ContextMenuStrip();
menu.Items.Add("&Open Webhook Server", null, (_, _) => ShowMainWindow());
menu.Items.Add(new ToolStripSeparator());
menu.Items.Add("&Restart service", null, async (_, _) => await _restartServiceAsync().ConfigureAwait(false));
menu.Items.Add(new ToolStripSeparator());
menu.Items.Add("E&xit", null, (_, _) => Application.Current.Shutdown());
return menu;
}
private void ShowMainWindow()
{
var w = _resolveMainWindow();
if (w is null) return;
if (w.WindowState == WindowState.Minimized) w.WindowState = WindowState.Normal;
w.Show();
w.Activate();
w.Topmost = true;
w.Topmost = false;
}
private static Icon LoadEmbeddedIcon()
{
// Pulled from the WPF Resource items in the csproj via the application
// pack URI. Falling back to SystemIcons keeps the tray usable if the
// resource is somehow missing.
try
{
var uri = new Uri("pack://application:,,,/webhook-server.ico", UriKind.Absolute);
using var stream = Application.GetResourceStream(uri).Stream;
return new Icon(stream);
}
catch
{
return SystemIcons.Application;
}
}
public void ShowBalloon(string title, string message)
{
_icon.BalloonTipTitle = title;
_icon.BalloonTipText = message;
_icon.ShowBalloonTip(3000);
}
public void Dispose()
{
_icon.Visible = false;
_icon.Dispose();
}
}
@@ -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,142 @@ public sealed partial class MainViewModel : ObservableObject
}
}
[ObservableProperty] private System.Collections.ObjectModel.ObservableCollection<BackupEntry> _backups = new();
[RelayCommand]
private async Task RefreshBackupsAsync()
{
try
{
var list = await _client.ListBackupsAsync().ConfigureAwait(false);
Application.Current.Dispatcher.Invoke(() =>
{
Backups.Clear();
foreach (var b in list) Backups.Add(b);
});
}
catch { /* ignore - checkpoint listing isn't critical */ }
}
[RelayCommand]
private async Task RestoreBackupAsync(BackupEntry? entry)
{
if (entry is null) return;
var ok = MessageBox.Show(
$"Restore the configuration from the checkpoint taken at {entry.SavedAt:yyyy-MM-dd HH:mm}?\n\nThe current configuration is automatically saved as a new checkpoint first, so you can roll forward again.",
"Restore checkpoint",
MessageBoxButton.OKCancel,
MessageBoxImage.Question);
if (ok != MessageBoxResult.OK) return;
try
{
await _client.RestoreBackupAsync(entry.FileName).ConfigureAwait(false);
await RefreshAsync().ConfigureAwait(false);
}
catch (Exception ex) { ShowError("Restore failed", ex); }
}
[RelayCommand]
private async Task ExportConfigAsync()
{
try
{
var snap = await _client.GetConfigAsync().ConfigureAwait(false);
if (snap is null) { ShowError("Export failed", new InvalidOperationException("Service did not return a config.")); return; }
var dlg = new Microsoft.Win32.SaveFileDialog
{
FileName = $"webhook-server-config-{DateTime.Now:yyyyMMdd-HHmmss}.json",
DefaultExt = ".json",
Filter = "JSON config (*.json)|*.json",
};
if (dlg.ShowDialog() != true) return;
var json = System.Text.Json.JsonSerializer.Serialize(snap, WebhookServer.Core.Storage.ConfigJson.Pretty);
await System.IO.File.WriteAllTextAsync(dlg.FileName, json).ConfigureAwait(false);
}
catch (Exception ex) { ShowError("Export failed", ex); }
}
[RelayCommand]
private async Task ImportConfigAsync()
{
var dlg = new Microsoft.Win32.OpenFileDialog
{
Filter = "JSON config (*.json)|*.json",
CheckFileExists = true,
};
if (dlg.ShowDialog() != true) return;
try
{
var json = await System.IO.File.ReadAllTextAsync(dlg.FileName).ConfigureAwait(false);
var cfg = System.Text.Json.JsonSerializer.Deserialize<ServerConfig>(json, WebhookServer.Core.Storage.ConfigJson.Pretty);
if (cfg is null) throw new InvalidOperationException("File did not contain a valid config.");
var ok = MessageBox.Show(
$"Replace the current configuration with {dlg.FileName}?\n\nA checkpoint of the current config is saved first, so you can roll back from File → Config Checkpoints.",
"Import config",
MessageBoxButton.OKCancel,
MessageBoxImage.Warning);
if (ok != MessageBoxResult.OK) return;
await _client.ImportConfigAsync(cfg).ConfigureAwait(false);
await RefreshAsync().ConfigureAwait(false);
}
catch (Exception ex) { ShowError("Import failed", ex); }
}
[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 +324,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,43 @@
<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="360" Width="440"
ResizeMode="NoResize"
WindowStartupLocation="CenterOwner"
Icon="/webhook-server.ico"
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)
@@ -14,6 +14,15 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF>
<UseWindowsForms>true</UseWindowsForms>
<ApplicationIcon>..\..\resources\webhook-server.ico</ApplicationIcon>
<ApplicationManifest>app.manifest</ApplicationManifest>
<AssemblyTitle>Webhook Server</AssemblyTitle>
</PropertyGroup>
<ItemGroup>
<Resource Include="..\..\resources\webhook-server.ico" Link="webhook-server.ico" />
<Resource Include="..\..\resources\webhook-server.png" Link="webhook-server.png" />
</ItemGroup>
</Project>
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="WebhookServer.Gui"/>
<!-- The GUI talks to the service via a named pipe ACL'd to SYSTEM and the
Administrators group. UAC token splitting denies that group on the
standard user token, so without elevation the pipe connect fails with
"Access is denied". Always run elevated. Start Menu shortcuts and the
installer's post-install launch both honor this. -->
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v2">
<requestedExecutionLevel level="requireAdministrator" uiAccess="false"/>
</requestedPrivileges>
</security>
</trustInfo>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- Windows 10 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
</application>
</compatibility>
</assembly>
+71 -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();
}
@@ -193,11 +202,66 @@ internal sealed class AdminPipeServer : BackgroundService
return AdminResponse.Success(new { lines });
}
case AdminOps.ListBackups:
{
var entries = ListBackups();
return AdminResponse.Success(new { backups = entries });
}
case AdminOps.RestoreBackup:
{
var args = DeserializeData<RestoreBackupArgs>(request) ?? throw new ArgumentException("missing fileName");
var restored = await RestoreBackupAsync(args.FileName, ct).ConfigureAwait(false);
_logger.LogInformation("Restored config from backup {File}", args.FileName);
return AdminResponse.Success(SafeSnapshotForWire(restored));
}
case AdminOps.ImportConfig:
{
var incoming = DeserializeData<ServerConfig>(request) ?? throw new ArgumentException("missing config payload");
MergeWithExistingSecrets(incoming, _state.Snapshot());
await _state.ReplaceAsync(incoming, ct).ConfigureAwait(false);
_logger.LogInformation("Config imported ({Count} endpoints)", incoming.Endpoints.Count);
return AdminResponse.Success(SafeSnapshotForWire(_state.Snapshot()));
}
default:
return AdminResponse.Failure($"unknown op '{request.Op}'");
}
}
private static List<BackupEntry> ListBackups()
{
var dir = Path.Combine(ServicePaths.DataRoot, "backups");
if (!Directory.Exists(dir)) return new List<BackupEntry>();
return new DirectoryInfo(dir).GetFiles("config-*.json")
.OrderByDescending(f => f.Name)
.Take(50)
.Select(f => new BackupEntry
{
FileName = f.Name,
SavedAt = f.LastWriteTimeUtc,
SizeBytes = f.Length,
})
.ToList();
}
private async Task<ServerConfig> RestoreBackupAsync(string fileName, CancellationToken ct)
{
// Refuse anything that tries to escape the backups directory.
if (fileName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0)
throw new ArgumentException("invalid file name");
var backupPath = Path.Combine(ServicePaths.DataRoot, "backups", fileName);
if (!File.Exists(backupPath))
throw new FileNotFoundException("backup not found", fileName);
await using var fs = File.OpenRead(backupPath);
var cfg = await JsonSerializer.DeserializeAsync<ServerConfig>(fs, ConfigJson.Pretty, ct).ConfigureAwait(false)
?? throw new InvalidOperationException("backup file was empty");
await _state.ReplaceAsync(cfg, ct).ConfigureAwait(false);
return _state.Snapshot();
}
private ServerConfig CloneSnapshotForEdit()
{
// Round-trip via JSON to avoid sharing references with the live snapshot.
@@ -214,16 +278,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 +311,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)