Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a45d994c18 | |||
| 28479272d5 | |||
| 4ef8d20578 | |||
| 8b855ec9b9 | |||
| 1e48b8185b | |||
| 24d8701b65 | |||
| 27e5264714 | |||
| ab53c96928 | |||
| 87bcb6807f | |||
| 882d5332b4 |
@@ -0,0 +1,14 @@
|
|||||||
|
<Project>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<Version>0.1.0</Version>
|
||||||
|
<Authors>Justin Paul</Authors>
|
||||||
|
<Company>Justin Paul</Company>
|
||||||
|
<Product>Webhook Server</Product>
|
||||||
|
<Copyright>Copyright (c) Justin Paul</Copyright>
|
||||||
|
<PackageProjectUrl>https://jpaul.me</PackageProjectUrl>
|
||||||
|
<RepositoryUrl>https://github.com/recklessop/webhook-server</RepositoryUrl>
|
||||||
|
<RepositoryType>git</RepositoryType>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Dev launcher: starts the service in one window and the GUI in another, both
|
||||||
|
pointing at an isolated data root so production %ProgramData% is not touched.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
MUST be run from an elevated PowerShell - the admin pipe is ACL'd to SYSTEM
|
||||||
|
and the Administrators group, and a non-elevated process cannot connect.
|
||||||
|
#>
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[string]$DataRoot = (Join-Path $env:TEMP 'webhook-dev'),
|
||||||
|
[int]$HttpPort = 18080
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$root = Split-Path -Parent $PSScriptRoot
|
||||||
|
$servicePath = Join-Path $root 'publish\service\WebhookServer.Service.exe'
|
||||||
|
$guiPath = Join-Path $root 'publish\gui\WebhookServer.Gui.exe'
|
||||||
|
|
||||||
|
if (-not (Test-Path $servicePath)) { throw "Service not built. Run: dotnet publish src/WebhookServer.Service -c Release -o publish/service" }
|
||||||
|
if (-not (Test-Path $guiPath)) { throw "GUI not built. Run: dotnet publish src/WebhookServer.Gui -c Release -o publish/gui" }
|
||||||
|
|
||||||
|
# Verify the current shell is elevated.
|
||||||
|
$currentPrincipal = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent())
|
||||||
|
if (-not $currentPrincipal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
|
||||||
|
throw 'This script must be run from an elevated PowerShell so the GUI can connect to the SYSTEM/Admins-only admin pipe.'
|
||||||
|
}
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Path $DataRoot -Force | Out-Null
|
||||||
|
New-Item -ItemType Directory -Path (Join-Path $DataRoot 'logs') -Force | Out-Null
|
||||||
|
|
||||||
|
$cfgPath = Join-Path $DataRoot 'config.json'
|
||||||
|
if (-not (Test-Path $cfgPath)) {
|
||||||
|
$cfg = @"
|
||||||
|
{
|
||||||
|
"httpPort": $HttpPort,
|
||||||
|
"trustedProxies": [],
|
||||||
|
"logRetentionDays": 7,
|
||||||
|
"endpoints": [
|
||||||
|
{
|
||||||
|
"id": "11111111-1111-1111-1111-111111111111",
|
||||||
|
"slug": "ping",
|
||||||
|
"description": "Trivial sync hook",
|
||||||
|
"enabled": true,
|
||||||
|
"allowedClients": [],
|
||||||
|
"authMode": "none",
|
||||||
|
"executorType": "windowsPowerShell",
|
||||||
|
"inlineCommand": "Write-Output 'pong'",
|
||||||
|
"executableArgs": [],
|
||||||
|
"dataPassing": { "stdinJson": false, "envVars": false, "argTemplate": false },
|
||||||
|
"responseMode": "sync",
|
||||||
|
"timeoutSeconds": 30,
|
||||||
|
"failOnNonZeroExit": true,
|
||||||
|
"serialize": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
"@
|
||||||
|
Set-Content -Path $cfgPath -Value $cfg -Encoding utf8
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Data root : $DataRoot"
|
||||||
|
Write-Host "Config : $cfgPath"
|
||||||
|
Write-Host "Service exe: $servicePath"
|
||||||
|
Write-Host "GUI exe : $guiPath"
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
$serviceArgs = @(
|
||||||
|
'-NoExit', '-NoProfile', '-Command',
|
||||||
|
"`$env:WEBHOOKSERVER_DATA = '$DataRoot'; & '$servicePath'"
|
||||||
|
)
|
||||||
|
Start-Process powershell -ArgumentList $serviceArgs -WindowStyle Normal
|
||||||
|
|
||||||
|
Start-Sleep -Seconds 2
|
||||||
|
|
||||||
|
# GUI inherits this shell's environment automatically.
|
||||||
|
$env:WEBHOOKSERVER_DATA = $DataRoot
|
||||||
|
Start-Process -FilePath $guiPath
|
||||||
|
|
||||||
|
Write-Host "Service window opened; GUI launched."
|
||||||
|
Write-Host "Hit http://localhost:$HttpPort/healthz to confirm Kestrel is up."
|
||||||
|
Write-Host "Logs: $(Join-Path $DataRoot 'logs')"
|
||||||
+29
-16
@@ -14,8 +14,8 @@
|
|||||||
.PARAMETER ServiceAccount
|
.PARAMETER ServiceAccount
|
||||||
Account to run the service under. Defaults to LocalSystem.
|
Account to run the service under. Defaults to LocalSystem.
|
||||||
For Active-Directory-aware hooks pass a domain user (DOMAIN\user) or a gMSA
|
For Active-Directory-aware hooks pass a domain user (DOMAIN\user) or a gMSA
|
||||||
(DOMAIN\svc-name$ — note the trailing $). Domain users require -Password.
|
(DOMAIN\svc-name$ - note the trailing $). Domain users require -Password.
|
||||||
Never pass LocalService — it has no network identity and cannot reach a DC.
|
Never pass LocalService - it has no network identity and cannot reach a DC.
|
||||||
|
|
||||||
.PARAMETER Password
|
.PARAMETER Password
|
||||||
Password for a domain-user account. Not required for LocalSystem, NetworkService,
|
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"
|
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
|
$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) {
|
if ($existing) {
|
||||||
Write-Host "Service '$ServiceName' already exists; updating binPath and account."
|
Write-Host "Service '$ServiceName' already exists; updating binPath and account."
|
||||||
sc.exe config $ServiceName binPath= "`"$BinaryPath`"" obj= $obj $(if ($Password) { "password= $Password" }) | Out-Null
|
$configArgs = @(
|
||||||
} else {
|
'config', $ServiceName,
|
||||||
$args = @(
|
'binPath=', "`"$BinaryPath`"",
|
||||||
'create', $ServiceName,
|
'obj=', $obj
|
||||||
"binPath=", "`"$BinaryPath`"",
|
|
||||||
"DisplayName=", "`"$DisplayName`"",
|
|
||||||
"start=", "auto",
|
|
||||||
"obj=", $obj
|
|
||||||
)
|
)
|
||||||
if ($Password) { $args += @('password=', $Password) }
|
if ($Password) { $configArgs += @('password=', $Password) }
|
||||||
sc.exe @args | Out-Null
|
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.
|
# 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'..."
|
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
|
Start-Sleep -Seconds 1
|
||||||
sc.exe query $ServiceName
|
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.Diagnostics;
|
||||||
|
using System.Runtime.Versioning;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using WebhookServer.Core.Execution.Native;
|
||||||
using WebhookServer.Core.Models;
|
using WebhookServer.Core.Models;
|
||||||
|
|
||||||
namespace WebhookServer.Core.Execution;
|
namespace WebhookServer.Core.Execution;
|
||||||
|
|
||||||
|
[SupportedOSPlatform("windows")]
|
||||||
public sealed class ProcessExecutor : IExecutor
|
public sealed class ProcessExecutor : IExecutor
|
||||||
{
|
{
|
||||||
/// <summary>Per-stream cap on captured output (excess is dropped and StdoutTruncated set).</summary>
|
/// <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)
|
public async Task<ExecutionResult> RunAsync(EndpointConfig endpoint, ExecutionContext ctx, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var startedAt = DateTimeOffset.UtcNow;
|
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 };
|
using var process = new Process { StartInfo = psi, EnableRaisingEvents = true };
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (!process.Start())
|
if (!process.Start())
|
||||||
{
|
|
||||||
return Failed(ctx.RunId, startedAt, "process failed to start");
|
return Failed(ctx.RunId, startedAt, "process failed to start");
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
return Failed(ctx.RunId, startedAt, $"launch error: {ex.Message}");
|
return Failed(ctx.RunId, startedAt, $"launch error: {ex.Message}");
|
||||||
}
|
}
|
||||||
|
|
||||||
// stdin
|
|
||||||
if (endpoint.DataPassing.StdinJson)
|
if (endpoint.DataPassing.StdinJson)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -42,15 +57,14 @@ public sealed class ProcessExecutor : IExecutor
|
|||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
try { process.StandardInput.Close(); } catch { /* swallow */ }
|
try { process.StandardInput.Close(); } catch { }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
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 stdoutTask = ReadCappedAsync(process.StandardOutput, ct);
|
||||||
var stderrTask = ReadCappedAsync(process.StandardError, ct);
|
var stderrTask = ReadCappedAsync(process.StandardError, ct);
|
||||||
|
|
||||||
@@ -66,8 +80,8 @@ public sealed class ProcessExecutor : IExecutor
|
|||||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
timedOut = true;
|
timedOut = true;
|
||||||
try { process.Kill(entireProcessTree: true); } catch { /* swallow */ }
|
try { process.Kill(entireProcessTree: true); } catch { }
|
||||||
try { await process.WaitForExitAsync(CancellationToken.None).ConfigureAwait(false); } catch { /* swallow */ }
|
try { await process.WaitForExitAsync(CancellationToken.None).ConfigureAwait(false); } catch { }
|
||||||
}
|
}
|
||||||
|
|
||||||
var (stdout, stdoutTrunc) = await stdoutTask.ConfigureAwait(false);
|
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
|
var psi = new ProcessStartInfo
|
||||||
{
|
{
|
||||||
@@ -131,18 +225,19 @@ public sealed class ProcessExecutor : IExecutor
|
|||||||
psi.ArgumentList.Add(arg);
|
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)
|
if (endpoint.DataPassing.EnvVars)
|
||||||
{
|
{
|
||||||
foreach (var (k, v) in ctx.Headers)
|
foreach (var (k, v) in ctx.Headers) envVars[$"WEBHOOK_HEADER_{Sanitize(k)}"] = v;
|
||||||
psi.Environment[$"WEBHOOK_HEADER_{Sanitize(k)}"] = v;
|
foreach (var (k, v) in ctx.Query) envVars[$"WEBHOOK_QUERY_{Sanitize(k)}"] = v;
|
||||||
foreach (var (k, v) in ctx.Query)
|
|
||||||
psi.Environment[$"WEBHOOK_QUERY_{Sanitize(k)}"] = v;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
psi.Environment["WEBHOOK_RUN_ID"] = ctx.RunId;
|
return (psi, envVars);
|
||||||
psi.Environment["WEBHOOK_SLUG"] = ctx.Slug;
|
|
||||||
|
|
||||||
return psi;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void AddPwshArgs(ProcessStartInfo psi, EndpointConfig endpoint)
|
private static void AddPwshArgs(ProcessStartInfo psi, EndpointConfig endpoint)
|
||||||
@@ -175,6 +270,13 @@ public sealed class ProcessExecutor : IExecutor
|
|||||||
return endpoint.InlineCommand ?? "";
|
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)
|
private static string Sanitize(string key)
|
||||||
{
|
{
|
||||||
var sb = new StringBuilder(key.Length);
|
var sb = new StringBuilder(key.Length);
|
||||||
@@ -203,7 +305,6 @@ public sealed class ProcessExecutor : IExecutor
|
|||||||
catch (IOException) { break; }
|
catch (IOException) { break; }
|
||||||
if (n == 0) break;
|
if (n == 0) break;
|
||||||
|
|
||||||
// Cheap byte estimate (ASCII-ish); good enough as a guard rail.
|
|
||||||
if (!truncated)
|
if (!truncated)
|
||||||
{
|
{
|
||||||
if (byteEstimate + n > MaxOutputBytes)
|
if (byteEstimate + n > MaxOutputBytes)
|
||||||
@@ -218,7 +319,6 @@ public sealed class ProcessExecutor : IExecutor
|
|||||||
byteEstimate += n;
|
byteEstimate += n;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Else keep draining without storing to keep the pipe from blocking.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (sb.ToString(), truncated);
|
return (sb.ToString(), truncated);
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ public sealed class StatusInfo
|
|||||||
public bool Running { get; set; }
|
public bool Running { get; set; }
|
||||||
public int HttpPort { get; set; }
|
public int HttpPort { get; set; }
|
||||||
public int? HttpsPort { get; set; }
|
public int? HttpsPort { get; set; }
|
||||||
|
public string? DisplayHost { get; set; }
|
||||||
public DateTimeOffset StartedAt { get; set; }
|
public DateTimeOffset StartedAt { get; set; }
|
||||||
public int EndpointCount { get; set; }
|
public int EndpointCount { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,4 +42,6 @@ public sealed class EndpointConfig
|
|||||||
public bool Serialize { get; set; }
|
public bool Serialize { get; set; }
|
||||||
|
|
||||||
public CallbackConfig? Callback { get; set; }
|
public CallbackConfig? Callback { get; set; }
|
||||||
|
|
||||||
|
public RunAsConfig? RunAs { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,3 +53,18 @@ public enum HttpsBindingKind
|
|||||||
PfxFile = 1,
|
PfxFile = 1,
|
||||||
CertStoreThumbprint = 2,
|
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 int HttpPort { get; set; } = 8080;
|
||||||
public HttpsBinding? HttpsBinding { get; set; }
|
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>
|
/// <summary>
|
||||||
/// IPs/CIDRs allowed to set X-Forwarded-For. Empty = forwarded headers are ignored
|
/// IPs/CIDRs allowed to set X-Forwarded-For. Empty = forwarded headers are ignored
|
||||||
/// and the direct connection IP is always used.
|
/// and the direct connection IP is always used.
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ public sealed class ConfigStore
|
|||||||
{
|
{
|
||||||
ClearOne(ep.Bearer?.Secret);
|
ClearOne(ep.Bearer?.Secret);
|
||||||
ClearOne(ep.Hmac?.Secret);
|
ClearOne(ep.Hmac?.Secret);
|
||||||
|
ClearOne(ep.RunAs?.Password);
|
||||||
if (ep.Callback is { } cb)
|
if (ep.Callback is { } cb)
|
||||||
{
|
{
|
||||||
ClearOne(cb.Bearer?.Secret);
|
ClearOne(cb.Bearer?.Secret);
|
||||||
@@ -76,6 +77,7 @@ public sealed class ConfigStore
|
|||||||
{
|
{
|
||||||
DecryptOne(ep.Bearer?.Secret);
|
DecryptOne(ep.Bearer?.Secret);
|
||||||
DecryptOne(ep.Hmac?.Secret);
|
DecryptOne(ep.Hmac?.Secret);
|
||||||
|
DecryptOne(ep.RunAs?.Password);
|
||||||
if (ep.Callback is { } cb)
|
if (ep.Callback is { } cb)
|
||||||
{
|
{
|
||||||
DecryptOne(cb.Bearer?.Secret);
|
DecryptOne(cb.Bearer?.Secret);
|
||||||
@@ -91,6 +93,7 @@ public sealed class ConfigStore
|
|||||||
{
|
{
|
||||||
EncryptOne(ep.Bearer?.Secret);
|
EncryptOne(ep.Bearer?.Secret);
|
||||||
EncryptOne(ep.Hmac?.Secret);
|
EncryptOne(ep.Hmac?.Secret);
|
||||||
|
EncryptOne(ep.RunAs?.Password);
|
||||||
if (ep.Callback is { } cb)
|
if (ep.Callback is { } cb)
|
||||||
{
|
{
|
||||||
EncryptOne(cb.Bearer?.Secret);
|
EncryptOne(cb.Bearer?.Secret);
|
||||||
|
|||||||
@@ -7,5 +7,7 @@
|
|||||||
<conv:NullToBoolConverter x:Key="NotNull"/>
|
<conv:NullToBoolConverter x:Key="NotNull"/>
|
||||||
<conv:BoolToBrushConverter x:Key="ConnFill"/>
|
<conv:BoolToBrushConverter x:Key="ConnFill"/>
|
||||||
<conv:StringEqualsConverter x:Key="StringEqualsConverter"/>
|
<conv:StringEqualsConverter x:Key="StringEqualsConverter"/>
|
||||||
|
<conv:HookUrlConverter x:Key="HookUrl"/>
|
||||||
|
<conv:InvertBoolConverter x:Key="InvertBool"/>
|
||||||
</Application.Resources>
|
</Application.Resources>
|
||||||
</Application>
|
</Application>
|
||||||
|
|||||||
@@ -13,6 +13,30 @@ public sealed class NullToBoolConverter : IValueConverter
|
|||||||
=> throw new NotSupportedException();
|
=> 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 sealed class StringEqualsConverter : IValueConverter
|
||||||
{
|
{
|
||||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||||
|
|||||||
@@ -8,6 +8,9 @@
|
|||||||
mc:Ignorable="d"
|
mc:Ignorable="d"
|
||||||
Title="Webhook Server" Height="600" Width="1000"
|
Title="Webhook Server" Height="600" Width="1000"
|
||||||
d:DataContext="{d:DesignInstance Type=vm:MainViewModel}">
|
d:DataContext="{d:DesignInstance Type=vm:MainViewModel}">
|
||||||
|
<Window.InputBindings>
|
||||||
|
<KeyBinding Key="N" Modifiers="Control" Command="{Binding AddEndpointCommand}"/>
|
||||||
|
</Window.InputBindings>
|
||||||
<DockPanel LastChildFill="True">
|
<DockPanel LastChildFill="True">
|
||||||
<StatusBar DockPanel.Dock="Bottom">
|
<StatusBar DockPanel.Dock="Bottom">
|
||||||
<StatusBarItem>
|
<StatusBarItem>
|
||||||
@@ -19,17 +22,25 @@
|
|||||||
</StatusBarItem>
|
</StatusBarItem>
|
||||||
</StatusBar>
|
</StatusBar>
|
||||||
|
|
||||||
<ToolBar DockPanel.Dock="Top">
|
<Menu DockPanel.Dock="Top">
|
||||||
<Button Content="Refresh" Command="{Binding RefreshCommand}"/>
|
<MenuItem Header="_File">
|
||||||
<Separator/>
|
<MenuItem Header="_New endpoint…" Command="{Binding AddEndpointCommand}" InputGestureText="Ctrl+N"/>
|
||||||
<Button Content="Add" Command="{Binding AddEndpointCommand}"/>
|
<Separator/>
|
||||||
<Button Content="Edit" Command="{Binding EditEndpointCommand}"
|
<MenuItem Header="_Import config…" IsEnabled="False" ToolTip="Coming soon"/>
|
||||||
IsEnabled="{Binding SelectedEndpoint, Converter={StaticResource NotNull}}"/>
|
<MenuItem Header="_Export config…" IsEnabled="False" ToolTip="Coming soon"/>
|
||||||
<Button Content="Delete" Command="{Binding DeleteEndpointCommand}"
|
<MenuItem Header="_Backups" IsEnabled="False" ToolTip="Coming soon"/>
|
||||||
IsEnabled="{Binding SelectedEndpoint, Converter={StaticResource NotNull}}"/>
|
<Separator/>
|
||||||
<Separator/>
|
<MenuItem Header="E_xit" Command="{Binding ExitCommand}"/>
|
||||||
<Button Content="Server Settings…" Command="{Binding EditServerSettingsCommand}"/>
|
</MenuItem>
|
||||||
</ToolBar>
|
<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>
|
||||||
<Grid.RowDefinitions>
|
<Grid.RowDefinitions>
|
||||||
@@ -46,6 +57,25 @@
|
|||||||
CanUserDeleteRows="False"
|
CanUserDeleteRows="False"
|
||||||
IsReadOnly="True"
|
IsReadOnly="True"
|
||||||
HeadersVisibility="Column">
|
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>
|
<DataGrid.Columns>
|
||||||
<DataGridTemplateColumn Header="Enabled" Width="80">
|
<DataGridTemplateColumn Header="Enabled" Width="80">
|
||||||
<DataGridTemplateColumn.CellTemplate>
|
<DataGridTemplateColumn.CellTemplate>
|
||||||
@@ -56,7 +86,25 @@
|
|||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</DataGridTemplateColumn.CellTemplate>
|
</DataGridTemplateColumn.CellTemplate>
|
||||||
</DataGridTemplateColumn>
|
</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="Auth" Binding="{Binding AuthMode}" Width="80"/>
|
||||||
<DataGridTextColumn Header="Executor" Binding="{Binding ExecutorType}" Width="140"/>
|
<DataGridTextColumn Header="Executor" Binding="{Binding ExecutorType}" Width="140"/>
|
||||||
<DataGridTextColumn Header="Mode" Binding="{Binding ResponseMode}" Width="80"/>
|
<DataGridTextColumn Header="Mode" Binding="{Binding ResponseMode}" Width="80"/>
|
||||||
@@ -71,16 +119,20 @@
|
|||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
<ColumnDefinition Width="*"/>
|
<ColumnDefinition Width="*"/>
|
||||||
<ColumnDefinition Width="Auto"/>
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
<TextBlock Text="Recent log entries" FontWeight="Bold" Margin="6,4"/>
|
<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>
|
</Grid>
|
||||||
<TextBox Text="{Binding LogTail, Mode=OneWay}"
|
<TextBox x:Name="LogTailBox"
|
||||||
|
Text="{Binding LogTail, Mode=OneWay}"
|
||||||
IsReadOnly="True"
|
IsReadOnly="True"
|
||||||
FontFamily="Consolas"
|
FontFamily="Consolas"
|
||||||
VerticalScrollBarVisibility="Auto"
|
VerticalScrollBarVisibility="Auto"
|
||||||
HorizontalScrollBarVisibility="Auto"
|
HorizontalScrollBarVisibility="Auto"
|
||||||
TextWrapping="NoWrap"/>
|
TextWrapping="NoWrap"
|
||||||
|
TextChanged="OnLogTailChanged"/>
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
using System.Windows;
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Input;
|
||||||
using WebhookServer.Gui.Services;
|
using WebhookServer.Gui.Services;
|
||||||
using WebhookServer.Gui.ViewModels;
|
using WebhookServer.Gui.ViewModels;
|
||||||
|
|
||||||
@@ -13,4 +15,16 @@ public partial class MainWindow : Window
|
|||||||
DataContext = vm;
|
DataContext = vm;
|
||||||
Loaded += async (_, _) => await vm.RefreshCommand.ExecuteAsync(null);
|
Loaded += async (_, _) => await vm.RefreshCommand.ExecuteAsync(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void OnLogTailChanged(object sender, TextChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is MainViewModel vm && vm.AutoScrollLogs && sender is TextBox box)
|
||||||
|
box.ScrollToEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnRowDoubleClick(object sender, MouseButtonEventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is MainViewModel vm && vm.EditEndpointCommand.CanExecute(null))
|
||||||
|
vm.EditEndpointCommand.Execute(null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Runtime.Versioning;
|
using System.Runtime.Versioning;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Windows;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using WebhookServer.Core.Models;
|
using WebhookServer.Core.Models;
|
||||||
@@ -22,6 +23,8 @@ public sealed partial class EndpointEditorViewModel : ObservableObject
|
|||||||
Endpoint = JsonSerializer.Deserialize<EndpointConfig>(json, ConfigJson.Compact)!;
|
Endpoint = JsonSerializer.Deserialize<EndpointConfig>(json, ConfigJson.Compact)!;
|
||||||
Endpoint.Bearer ??= new BearerOptions();
|
Endpoint.Bearer ??= new BearerOptions();
|
||||||
Endpoint.Hmac ??= new HmacOptions();
|
Endpoint.Hmac ??= new HmacOptions();
|
||||||
|
Endpoint.RunAs ??= new RunAsConfig();
|
||||||
|
Endpoint.RunAs.Password ??= new ProtectedString();
|
||||||
IsNew = isNew;
|
IsNew = isNew;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,6 +32,81 @@ public sealed partial class EndpointEditorViewModel : ObservableObject
|
|||||||
public Array ExecutorTypes { get; } = Enum.GetValues(typeof(ExecutorType));
|
public Array ExecutorTypes { get; } = Enum.GetValues(typeof(ExecutorType));
|
||||||
public Array ResponseModes { get; } = Enum.GetValues(typeof(ResponseMode));
|
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
|
public string AllowedClientsText
|
||||||
{
|
{
|
||||||
get => string.Join(Environment.NewLine, Endpoint.AllowedClients);
|
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
|
set
|
||||||
{
|
{
|
||||||
Endpoint.Bearer ??= new BearerOptions();
|
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
|
set
|
||||||
{
|
{
|
||||||
Endpoint.Hmac ??= new HmacOptions();
|
Endpoint.Hmac ??= new HmacOptions();
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using System.Text;
|
|||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using System.Windows;
|
using System.Windows;
|
||||||
|
using System.Windows.Threading;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using WebhookServer.Core.Ipc;
|
using WebhookServer.Core.Ipc;
|
||||||
@@ -24,11 +25,19 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
[ObservableProperty] private string _connectionStatus = "Disconnected";
|
[ObservableProperty] private string _connectionStatus = "Disconnected";
|
||||||
[ObservableProperty] private bool _isConnected;
|
[ObservableProperty] private bool _isConnected;
|
||||||
[ObservableProperty] private string _logTail = "";
|
[ObservableProperty] private string _logTail = "";
|
||||||
|
[ObservableProperty] private bool _autoScrollLogs = true;
|
||||||
[ObservableProperty] private ServerConfig _serverConfig = new();
|
[ObservableProperty] private ServerConfig _serverConfig = new();
|
||||||
|
[ObservableProperty] private string _httpBaseUrl = "http://localhost:8080";
|
||||||
|
[ObservableProperty] private string? _httpsBaseUrl;
|
||||||
|
|
||||||
|
private readonly DispatcherTimer _logTimer;
|
||||||
|
|
||||||
public MainViewModel(AdminPipeClient client)
|
public MainViewModel(AdminPipeClient client)
|
||||||
{
|
{
|
||||||
_client = client;
|
_client = client;
|
||||||
|
_logTimer = new DispatcherTimer(DispatcherPriority.Background) { Interval = TimeSpan.FromSeconds(3) };
|
||||||
|
_logTimer.Tick += async (_, _) => await RefreshLogTailAsync();
|
||||||
|
_logTimer.Start();
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
@@ -46,6 +55,13 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
? $"Connected — HTTP {status!.HttpPort}{(status.HttpsPort.HasValue ? $" / HTTPS {status.HttpsPort}" : "")}"
|
? $"Connected — HTTP {status!.HttpPort}{(status.HttpsPort.HasValue ? $" / HTTPS {status.HttpsPort}" : "")}"
|
||||||
: "Disconnected";
|
: "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();
|
Endpoints.Clear();
|
||||||
if (config is not null)
|
if (config is not null)
|
||||||
{
|
{
|
||||||
@@ -159,6 +175,56 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task RestartServiceAsync()
|
||||||
|
{
|
||||||
|
var ok = MessageBox.Show(
|
||||||
|
"Restart the WebhookServer service? In-flight requests will be aborted.",
|
||||||
|
"Restart service",
|
||||||
|
MessageBoxButton.OKCancel,
|
||||||
|
MessageBoxImage.Warning);
|
||||||
|
if (ok != MessageBoxResult.OK) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _client.RestartListenerAsync().ConfigureAwait(false);
|
||||||
|
await Task.Delay(2000).ConfigureAwait(false);
|
||||||
|
await RefreshAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
ShowError("Restart failed", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void ShowAbout()
|
||||||
|
{
|
||||||
|
var dlg = new Views.AboutDialog { Owner = Application.Current.MainWindow };
|
||||||
|
dlg.ShowDialog();
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void Exit()
|
||||||
|
{
|
||||||
|
Application.Current.Shutdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void CopyEndpointUrl()
|
||||||
|
{
|
||||||
|
if (SelectedEndpoint is null || string.IsNullOrEmpty(HttpBaseUrl)) return;
|
||||||
|
var url = $"{HttpBaseUrl.TrimEnd('/')}/hook/{SelectedEndpoint.Slug}";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Clipboard.SetText(url);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
ShowError("Copy failed", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task EditServerSettingsAsync()
|
private async Task EditServerSettingsAsync()
|
||||||
{
|
{
|
||||||
@@ -172,6 +238,8 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
ServerConfig.HttpPort = vm.HttpPort;
|
ServerConfig.HttpPort = vm.HttpPort;
|
||||||
ServerConfig.TrustedProxies = vm.TrustedProxiesList;
|
ServerConfig.TrustedProxies = vm.TrustedProxiesList;
|
||||||
ServerConfig.HttpsBinding = vm.BuildBinding();
|
ServerConfig.HttpsBinding = vm.BuildBinding();
|
||||||
|
ServerConfig.BindAddresses = vm.BindAddressesList;
|
||||||
|
ServerConfig.DisplayHost = vm.DisplayHostValue;
|
||||||
await _client.InvokeAsync(AdminOps.UpdateConfig, ServerConfig).ConfigureAwait(false);
|
await _client.InvokeAsync(AdminOps.UpdateConfig, ServerConfig).ConfigureAwait(false);
|
||||||
await RefreshAsync().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 System.Runtime.Versioning;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
@@ -13,9 +17,17 @@ public sealed partial class ServerSettingsViewModel : ObservableObject
|
|||||||
[ObservableProperty] private bool _httpsEnabled;
|
[ObservableProperty] private bool _httpsEnabled;
|
||||||
[ObservableProperty] private string _httpsMode = "PfxFile";
|
[ObservableProperty] private string _httpsMode = "PfxFile";
|
||||||
[ObservableProperty] private string _pfxPath = "";
|
[ObservableProperty] private string _pfxPath = "";
|
||||||
[ObservableProperty] private string _pfxPasswordInput = "";
|
[ObservableProperty] private string _pfxPassword = "";
|
||||||
[ObservableProperty] private string _thumbprint = "";
|
[ObservableProperty] private string _thumbprint = "";
|
||||||
[ObservableProperty] private string _trustedProxiesText = "";
|
[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; }
|
public bool Accepted { get; private set; }
|
||||||
|
|
||||||
@@ -29,12 +41,50 @@ public sealed partial class ServerSettingsViewModel : ObservableObject
|
|||||||
HttpsPort = b?.Port ?? 8443;
|
HttpsPort = b?.Port ?? 8443;
|
||||||
HttpsMode = b?.Kind == HttpsBindingKind.CertStoreThumbprint ? "Thumbprint" : "PfxFile";
|
HttpsMode = b?.Kind == HttpsBindingKind.CertStoreThumbprint ? "Thumbprint" : "PfxFile";
|
||||||
PfxPath = b?.PfxPath ?? "";
|
PfxPath = b?.PfxPath ?? "";
|
||||||
|
PfxPassword = b?.PfxPassword?.Plaintext ?? "";
|
||||||
Thumbprint = b?.Thumbprint ?? "";
|
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 =>
|
public List<string> TrustedProxiesList =>
|
||||||
(TrustedProxiesText ?? "").Split(new[] { '\r', '\n', ',' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
|
(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()
|
public HttpsBinding? BuildBinding()
|
||||||
{
|
{
|
||||||
if (!HttpsEnabled) return null;
|
if (!HttpsEnabled) return null;
|
||||||
@@ -49,12 +99,37 @@ public sealed partial class ServerSettingsViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
binding.Kind = HttpsBindingKind.PfxFile;
|
binding.Kind = HttpsBindingKind.PfxFile;
|
||||||
binding.PfxPath = PfxPath;
|
binding.PfxPath = PfxPath;
|
||||||
if (!string.IsNullOrEmpty(PfxPasswordInput))
|
if (!string.IsNullOrEmpty(PfxPassword))
|
||||||
binding.PfxPassword = ProtectedString.FromPlaintext(PfxPasswordInput);
|
binding.PfxPassword = ProtectedString.FromPlaintext(PfxPassword);
|
||||||
}
|
}
|
||||||
return binding;
|
return binding;
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void Save() => Accepted = true;
|
private void Save() => Accepted = true;
|
||||||
|
|
||||||
|
private static IEnumerable<(string Address, string Label)> DetectLocalAddresses()
|
||||||
|
{
|
||||||
|
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var ni in NetworkInterface.GetAllNetworkInterfaces())
|
||||||
|
{
|
||||||
|
if (ni.OperationalStatus != OperationalStatus.Up) continue;
|
||||||
|
if (ni.NetworkInterfaceType == NetworkInterfaceType.Tunnel) continue;
|
||||||
|
foreach (var ua in ni.GetIPProperties().UnicastAddresses)
|
||||||
|
{
|
||||||
|
if (ua.Address.AddressFamily != AddressFamily.InterNetwork &&
|
||||||
|
ua.Address.AddressFamily != AddressFamily.InterNetworkV6) continue;
|
||||||
|
var key = ua.Address.ToString();
|
||||||
|
if (!seen.Add(key)) continue;
|
||||||
|
yield return (key, $"{ni.Name} ({ni.NetworkInterfaceType})");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed partial class NetworkAddressRow : ObservableObject
|
||||||
|
{
|
||||||
|
public required string Address { get; init; }
|
||||||
|
public required string Label { get; init; }
|
||||||
|
[ObservableProperty] private bool _isBound;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<Window x:Class="WebhookServer.Gui.Views.AboutDialog"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
Title="About Webhook Server"
|
||||||
|
Height="320" Width="420"
|
||||||
|
ResizeMode="NoResize"
|
||||||
|
WindowStartupLocation="CenterOwner"
|
||||||
|
ShowInTaskbar="False">
|
||||||
|
<Grid Margin="20">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="0" Text="Webhook Server" FontSize="22" FontWeight="Bold"/>
|
||||||
|
<TextBlock Grid.Row="1" x:Name="VersionText" Foreground="Gray" Margin="0,4,0,16"/>
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="2" TextWrapping="Wrap">
|
||||||
|
A Windows-native webhook server that runs PowerShell, cmd, or arbitrary
|
||||||
|
executables in response to incoming HTTP requests.
|
||||||
|
</TextBlock>
|
||||||
|
|
||||||
|
<StackPanel Grid.Row="3" Margin="0,16,0,0">
|
||||||
|
<TextBlock>
|
||||||
|
<Run Text="Created by"/>
|
||||||
|
<Run Text="Justin Paul" FontWeight="SemiBold"/>
|
||||||
|
</TextBlock>
|
||||||
|
<TextBlock Margin="0,4,0,0">
|
||||||
|
<Hyperlink NavigateUri="https://jpaul.me" RequestNavigate="OnHyperlink">https://jpaul.me</Hyperlink>
|
||||||
|
</TextBlock>
|
||||||
|
<TextBlock Margin="0,4,0,0">
|
||||||
|
<Hyperlink NavigateUri="https://github.com/recklessop/webhook-server" RequestNavigate="OnHyperlink">github.com/recklessop/webhook-server</Hyperlink>
|
||||||
|
</TextBlock>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<Button Grid.Row="5" Content="OK" Width="80" HorizontalAlignment="Right" IsDefault="True" IsCancel="True" Click="OnOk"/>
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Navigation;
|
||||||
|
|
||||||
|
namespace WebhookServer.Gui.Views;
|
||||||
|
|
||||||
|
public partial class AboutDialog : Window
|
||||||
|
{
|
||||||
|
public AboutDialog()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
|
||||||
|
var asm = Assembly.GetExecutingAssembly();
|
||||||
|
var info = asm.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion
|
||||||
|
?? asm.GetName().Version?.ToString()
|
||||||
|
?? "0.0.0";
|
||||||
|
VersionText.Text = $"Version {info}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnHyperlink(object sender, RequestNavigateEventArgs e)
|
||||||
|
{
|
||||||
|
Process.Start(new ProcessStartInfo(e.Uri.AbsoluteUri) { UseShellExecute = true });
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnOk(object sender, RoutedEventArgs e) => Close();
|
||||||
|
}
|
||||||
@@ -47,32 +47,38 @@
|
|||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
<TextBlock Text="Mode" VerticalAlignment="Center"/>
|
<TextBlock Text="Mode" VerticalAlignment="Center"/>
|
||||||
<ComboBox Grid.Column="1" ItemsSource="{Binding AuthModes}"
|
<ComboBox Grid.Column="1" ItemsSource="{Binding AuthModes}"
|
||||||
SelectedItem="{Binding Endpoint.AuthMode, Mode=TwoWay}"/>
|
SelectedItem="{Binding SelectedAuthMode, Mode=TwoWay}"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid Margin="0,4,0,0">
|
<Grid Margin="0,4,0,0" Visibility="{Binding BearerVisible}">
|
||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
<ColumnDefinition Width="120"/>
|
<ColumnDefinition Width="120"/>
|
||||||
<ColumnDefinition Width="*"/>
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
<TextBlock Text="Bearer secret" VerticalAlignment="Center"/>
|
<TextBlock Text="Bearer secret" VerticalAlignment="Center"/>
|
||||||
<PasswordBox Grid.Column="1" PasswordChanged="OnBearerPasswordChanged"/>
|
<TextBox Grid.Column="1" Text="{Binding BearerSecret, UpdateSourceTrigger=PropertyChanged}" FontFamily="Consolas"/>
|
||||||
</Grid>
|
<Button Grid.Column="2" Content="Copy" Margin="4,0,0,0" Padding="6,0" Click="OnCopyBearer"/>
|
||||||
<Grid Margin="0,4,0,0">
|
|
||||||
<Grid.ColumnDefinitions>
|
|
||||||
<ColumnDefinition Width="120"/>
|
|
||||||
<ColumnDefinition Width="*"/>
|
|
||||||
</Grid.ColumnDefinitions>
|
|
||||||
<TextBlock Text="HMAC secret" VerticalAlignment="Center"/>
|
|
||||||
<PasswordBox Grid.Column="1" PasswordChanged="OnHmacPasswordChanged"/>
|
|
||||||
</Grid>
|
|
||||||
<Grid Margin="0,4,0,0">
|
|
||||||
<Grid.ColumnDefinitions>
|
|
||||||
<ColumnDefinition Width="120"/>
|
|
||||||
<ColumnDefinition Width="*"/>
|
|
||||||
</Grid.ColumnDefinitions>
|
|
||||||
<TextBlock Text="HMAC header" VerticalAlignment="Center"/>
|
|
||||||
<TextBox Grid.Column="1" Text="{Binding Endpoint.Hmac.HeaderName, UpdateSourceTrigger=PropertyChanged}"/>
|
|
||||||
</Grid>
|
</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"/>
|
||||||
|
<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>
|
||||||
|
<ColumnDefinition Width="120"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<TextBlock Text="HMAC header" VerticalAlignment="Center"/>
|
||||||
|
<TextBox Grid.Column="1" Text="{Binding Endpoint.Hmac.HeaderName, UpdateSourceTrigger=PropertyChanged}"/>
|
||||||
|
</Grid>
|
||||||
|
</StackPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</GroupBox>
|
</GroupBox>
|
||||||
|
|
||||||
@@ -124,6 +130,50 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</GroupBox>
|
</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">
|
<GroupBox Header="Response" Padding="6" Margin="0,8,0,0">
|
||||||
<Grid>
|
<Grid>
|
||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
|
|||||||
@@ -19,15 +19,15 @@ public partial class EndpointEditor : Window
|
|||||||
Close();
|
Close();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnBearerPasswordChanged(object sender, RoutedEventArgs e)
|
private void OnCopyBearer(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
if (DataContext is EndpointEditorViewModel vm && sender is PasswordBox box)
|
if (DataContext is EndpointEditorViewModel vm && !string.IsNullOrEmpty(vm.BearerSecret))
|
||||||
vm.BearerSecretInput = box.Password;
|
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)
|
if (DataContext is EndpointEditorViewModel vm && !string.IsNullOrEmpty(vm.HmacSecret))
|
||||||
vm.HmacSecretInput = box.Password;
|
try { Clipboard.SetText(vm.HmacSecret); } catch { /* clipboard busy — silent */ }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
xmlns:vm="clr-namespace:WebhookServer.Gui.ViewModels"
|
xmlns:vm="clr-namespace:WebhookServer.Gui.ViewModels"
|
||||||
mc:Ignorable="d"
|
mc:Ignorable="d"
|
||||||
Title="Server Settings" Height="500" Width="540"
|
Title="Server Settings" Height="720" Width="600"
|
||||||
WindowStartupLocation="CenterOwner"
|
WindowStartupLocation="CenterOwner"
|
||||||
d:DataContext="{d:DesignInstance Type=vm:ServerSettingsViewModel}">
|
d:DataContext="{d:DesignInstance Type=vm:ServerSettingsViewModel}">
|
||||||
<DockPanel Margin="12">
|
<DockPanel Margin="12">
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
<Button Content="Cancel" Width="80" IsCancel="True"/>
|
<Button Content="Cancel" Width="80" IsCancel="True"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
|
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||||
<StackPanel>
|
<StackPanel>
|
||||||
<GroupBox Header="HTTP" Padding="6">
|
<GroupBox Header="HTTP" Padding="6">
|
||||||
<Grid>
|
<Grid>
|
||||||
@@ -26,6 +27,41 @@
|
|||||||
</Grid>
|
</Grid>
|
||||||
</GroupBox>
|
</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">
|
<GroupBox Header="HTTPS" Padding="6" Margin="0,8,0,0">
|
||||||
<StackPanel>
|
<StackPanel>
|
||||||
<CheckBox Content="Enabled" IsChecked="{Binding HttpsEnabled}"/>
|
<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"/>
|
<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"/>
|
<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"/>
|
<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"/>
|
<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"/>
|
<TextBox Text="{Binding TrustedProxiesText, UpdateSourceTrigger=LostFocus}" AcceptsReturn="True" MinHeight="80" TextWrapping="Wrap" VerticalScrollBarVisibility="Auto"/>
|
||||||
</GroupBox>
|
</GroupBox>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
</Window>
|
</Window>
|
||||||
|
|||||||
@@ -19,12 +19,6 @@ public partial class ServerSettings : Window
|
|||||||
Close();
|
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)
|
private void OnModeChecked(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
if (DataContext is ServerSettingsViewModel vm && sender is RadioButton rb && rb.Tag is string tag)
|
if (DataContext is ServerSettingsViewModel vm && sender is RadioButton rb && rb.Tag is string tag)
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ internal sealed class AdminPipeServer : BackgroundService
|
|||||||
Running = true,
|
Running = true,
|
||||||
HttpPort = snap.HttpPort,
|
HttpPort = snap.HttpPort,
|
||||||
HttpsPort = snap.HttpsBinding?.Port,
|
HttpsPort = snap.HttpsBinding?.Port,
|
||||||
|
DisplayHost = snap.DisplayHost,
|
||||||
StartedAt = _state.StartedAt,
|
StartedAt = _state.StartedAt,
|
||||||
EndpointCount = snap.Endpoints.Count,
|
EndpointCount = snap.Endpoints.Count,
|
||||||
});
|
});
|
||||||
@@ -120,6 +121,7 @@ internal sealed class AdminPipeServer : BackgroundService
|
|||||||
var incoming = DeserializeData<ServerConfig>(request) ?? throw new ArgumentException("missing config payload");
|
var incoming = DeserializeData<ServerConfig>(request) ?? throw new ArgumentException("missing config payload");
|
||||||
MergeWithExistingSecrets(incoming, _state.Snapshot());
|
MergeWithExistingSecrets(incoming, _state.Snapshot());
|
||||||
await _state.ReplaceAsync(incoming, ct).ConfigureAwait(false);
|
await _state.ReplaceAsync(incoming, ct).ConfigureAwait(false);
|
||||||
|
_logger.LogInformation("Server config replaced ({Count} endpoints)", incoming.Endpoints.Count);
|
||||||
return AdminResponse.Success(SafeSnapshotForWire(_state.Snapshot()));
|
return AdminResponse.Success(SafeSnapshotForWire(_state.Snapshot()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,6 +137,7 @@ internal sealed class AdminPipeServer : BackgroundService
|
|||||||
return AdminResponse.Failure($"slug '{ep.Slug}' already exists");
|
return AdminResponse.Failure($"slug '{ep.Slug}' already exists");
|
||||||
next.Endpoints.Add(ep);
|
next.Endpoints.Add(ep);
|
||||||
await _state.ReplaceAsync(next, ct).ConfigureAwait(false);
|
await _state.ReplaceAsync(next, ct).ConfigureAwait(false);
|
||||||
|
_logger.LogInformation("Endpoint created: {Slug} ({Id})", ep.Slug, ep.Id);
|
||||||
return AdminResponse.Success(ep);
|
return AdminResponse.Success(ep);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,6 +150,7 @@ internal sealed class AdminPipeServer : BackgroundService
|
|||||||
MergeEndpointSecrets(ep, next.Endpoints[idx]);
|
MergeEndpointSecrets(ep, next.Endpoints[idx]);
|
||||||
next.Endpoints[idx] = ep;
|
next.Endpoints[idx] = ep;
|
||||||
await _state.ReplaceAsync(next, ct).ConfigureAwait(false);
|
await _state.ReplaceAsync(next, ct).ConfigureAwait(false);
|
||||||
|
_logger.LogInformation("Endpoint updated: {Slug} ({Id})", ep.Slug, ep.Id);
|
||||||
return AdminResponse.Success(ep);
|
return AdminResponse.Success(ep);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,6 +161,7 @@ internal sealed class AdminPipeServer : BackgroundService
|
|||||||
var removed = next.Endpoints.RemoveAll(e => e.Id == args.Id);
|
var removed = next.Endpoints.RemoveAll(e => e.Id == args.Id);
|
||||||
if (removed == 0) return AdminResponse.Failure("endpoint not found");
|
if (removed == 0) return AdminResponse.Failure("endpoint not found");
|
||||||
await _state.ReplaceAsync(next, ct).ConfigureAwait(false);
|
await _state.ReplaceAsync(next, ct).ConfigureAwait(false);
|
||||||
|
_logger.LogInformation("Endpoint deleted: {Id}", args.Id);
|
||||||
return AdminResponse.Success();
|
return AdminResponse.Success();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,8 +172,10 @@ internal sealed class AdminPipeServer : BackgroundService
|
|||||||
var next = CloneSnapshotForEdit();
|
var next = CloneSnapshotForEdit();
|
||||||
var ep = next.Endpoints.FirstOrDefault(e => e.Id == args.Id);
|
var ep = next.Endpoints.FirstOrDefault(e => e.Id == args.Id);
|
||||||
if (ep is null) return AdminResponse.Failure("endpoint not found");
|
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);
|
await _state.ReplaceAsync(next, ct).ConfigureAwait(false);
|
||||||
|
_logger.LogInformation("Endpoint {Slug} {State}", ep.Slug, newState ? "enabled" : "disabled");
|
||||||
return AdminResponse.Success(ep);
|
return AdminResponse.Success(ep);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,6 +185,8 @@ internal sealed class AdminPipeServer : BackgroundService
|
|||||||
var next = CloneSnapshotForEdit();
|
var next = CloneSnapshotForEdit();
|
||||||
next.HttpsBinding = binding;
|
next.HttpsBinding = binding;
|
||||||
await _state.ReplaceAsync(next, ct).ConfigureAwait(false);
|
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();
|
return AdminResponse.Success();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,16 +223,15 @@ internal sealed class AdminPipeServer : BackgroundService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Strip plaintext secrets from a snapshot before sending to the GUI. Encrypted
|
/// Deep-clone the snapshot for the GUI. Plaintext secrets ARE included on the
|
||||||
/// blobs are useless to the GUI but harmless; plaintext must never leak.
|
/// 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>
|
/// </summary>
|
||||||
private static ServerConfig SafeSnapshotForWire(ServerConfig snap)
|
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 json = JsonSerializer.Serialize(snap, ConfigJson.Compact);
|
||||||
var clone = JsonSerializer.Deserialize<ServerConfig>(json, ConfigJson.Compact)!;
|
return JsonSerializer.Deserialize<ServerConfig>(json, ConfigJson.Compact)!;
|
||||||
ConfigStore.ClearPlaintexts(clone);
|
|
||||||
return clone;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -248,6 +256,7 @@ internal sealed class AdminPipeServer : BackgroundService
|
|||||||
{
|
{
|
||||||
if (incoming.Bearer is { } a) MergeProtected(a.Secret, prior.Bearer?.Secret);
|
if (incoming.Bearer is { } a) MergeProtected(a.Secret, prior.Bearer?.Secret);
|
||||||
if (incoming.Hmac is { } h) MergeProtected(h.Secret, prior.Hmac?.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 (incoming.Callback is { } cb)
|
||||||
{
|
{
|
||||||
if (cb.Bearer is { } cba) MergeProtected(cba.Secret, prior.Callback?.Bearer?.Secret);
|
if (cb.Bearer is { } cba) MergeProtected(cba.Secret, prior.Callback?.Bearer?.Secret);
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ try
|
|||||||
|
|
||||||
builder.WebHost.ConfigureKestrel(opts =>
|
builder.WebHost.ConfigureKestrel(opts =>
|
||||||
{
|
{
|
||||||
opts.ListenAnyIP(initialConfig.HttpPort);
|
ConfigureHttp(opts, initialConfig);
|
||||||
ConfigureHttps(opts, initialConfig.HttpsBinding);
|
ConfigureHttps(opts, initialConfig.HttpsBinding);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -62,7 +62,10 @@ try
|
|||||||
lifetime.StopApplication();
|
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>();
|
var router = http.RequestServices.GetRequiredService<WebhookRouter>();
|
||||||
await router.HandleAsync(http, slug);
|
await router.HandleAsync(http, slug);
|
||||||
@@ -70,6 +73,9 @@ try
|
|||||||
|
|
||||||
app.MapGet("/healthz", () => Results.Ok(new { ok = true }));
|
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);
|
await app.RunAsync().ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -81,6 +87,24 @@ finally
|
|||||||
Log.CloseAndFlush();
|
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)
|
static void ConfigureHttps(Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerOptions opts, HttpsBinding? binding)
|
||||||
{
|
{
|
||||||
if (binding is null || binding.Kind == HttpsBindingKind.None) return;
|
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)
|
private static bool HasListenerSettingsChanged(ServerConfig oldCfg, ServerConfig newCfg)
|
||||||
{
|
{
|
||||||
if (oldCfg.HttpPort != newCfg.HttpPort) return true;
|
if (oldCfg.HttpPort != newCfg.HttpPort) return true;
|
||||||
|
if (!oldCfg.BindAddresses.SequenceEqual(newCfg.BindAddresses, StringComparer.OrdinalIgnoreCase)) return true;
|
||||||
var a = oldCfg.HttpsBinding;
|
var a = oldCfg.HttpsBinding;
|
||||||
var b = newCfg.HttpsBinding;
|
var b = newCfg.HttpsBinding;
|
||||||
if ((a is null) != (b is null)) return true;
|
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)
|
if (a.Kind != b.Kind || a.Port != b.Port || a.PfxPath != b.PfxPath || a.Thumbprint != b.Thumbprint)
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
// DisplayHost is cosmetic; don't restart for it.
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ public sealed class WebhookRouter
|
|||||||
}
|
}
|
||||||
|
|
||||||
var result = await RunAsync(endpoint, ctx, http.RequestAborted).ConfigureAwait(false);
|
var result = await RunAsync(endpoint, ctx, http.RequestAborted).ConfigureAwait(false);
|
||||||
|
LogResult(endpoint, ctx, result);
|
||||||
DispatchCallback(endpoint, ctx, result);
|
DispatchCallback(endpoint, ctx, result);
|
||||||
|
|
||||||
if (result.LaunchError is not null)
|
if (result.LaunchError is not null)
|
||||||
@@ -157,6 +158,7 @@ public sealed class WebhookRouter
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var result = await RunAsync(endpoint, ctx, ct).ConfigureAwait(false);
|
var result = await RunAsync(endpoint, ctx, ct).ConfigureAwait(false);
|
||||||
|
LogResult(endpoint, ctx, result);
|
||||||
DispatchCallback(endpoint, ctx, result);
|
DispatchCallback(endpoint, ctx, result);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
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)
|
private async Task<ExecutionResult> RunAsync(EndpointConfig endpoint, ExecCtx ctx, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (endpoint.Serialize)
|
if (endpoint.Serialize)
|
||||||
|
|||||||
Reference in New Issue
Block a user