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>
This commit is contained in:
2026-05-08 08:47:11 -04:00
parent 882d5332b4
commit 87bcb6807f
15 changed files with 299 additions and 62 deletions
+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"
+17 -11
View File
@@ -48,23 +48,29 @@ 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
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 | Out-Null
} else {
$createArgs = @(
'create', $ServiceName,
'binPath=', "`"$BinaryPath`"",
'DisplayName=', "`"$DisplayName`"",
'start=', 'auto',
'obj=', $obj
)
if ($Password) { $createArgs += @('password=', $Password) }
sc.exe @createArgs | Out-Null
}
# Configure failure recovery: restart the service on first/second failure, reset count after a day.