Per-endpoint RunAs: Service / InteractiveUser / SpecificUser
Native per-endpoint identity instead of the schtasks bridge: - Service (default) keeps the existing path - hooks inherit the service account (SYSTEM by default, or whatever you installed under). - SpecificUser binds ProcessStartInfo.UserName / Password / Domain so the hook runs in a batch logon session as the named account. Useful for AD-write hooks that should NOT run as SYSTEM. - InteractiveUser uses WTSQueryUserToken(WTSGetActiveConsoleSessionId) + DuplicateTokenEx + CreateProcessAsUser to drop the child into the logged-in user's session with their environment block. This is the real fix for "calc.exe should pop up on my desktop" - no Task Scheduler bridge required. Stdio is captured via inheritable anonymous pipes so the hook still returns stdout/stderr to the caller normally. Implementation: - New RunAsMode enum + RunAsConfig model on EndpointConfig - ConfigStore round-trips RunAs.Password through DPAPI alongside bearer/HMAC/PFX secrets - AdminPipeServer's secret-merge logic preserves the encrypted blob when the GUI saves an endpoint without re-typing the password - New WebhookServer.Core.Execution.Native namespace with NativeMethods (P/Invoke) and InteractiveProcessLauncher (token-based launcher) - ProcessExecutor branches on RunAs.Mode; the Service/SpecificUser paths share .NET's Process; InteractiveUser uses the launcher - GUI editor gets a "Run as" section: dropdown + conditional username/password/load-profile fields under SpecificUser Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,287 @@
|
||||
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 { }
|
||||
}
|
||||
}
|
||||
|
||||
public static LaunchResult Launch(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)");
|
||||
|
||||
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(userToken, (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,
|
||||
lpDesktop = @"winsta0\default",
|
||||
};
|
||||
|
||||
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 (userToken != IntPtr.Zero) CloseHandle(userToken);
|
||||
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,144 @@
|
||||
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;
|
||||
}
|
||||
|
||||
[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,13 @@
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.Versioning;
|
||||
using System.Security;
|
||||
using System.Text;
|
||||
using WebhookServer.Core.Execution.Native;
|
||||
using WebhookServer.Core.Models;
|
||||
|
||||
namespace WebhookServer.Core.Execution;
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
public sealed class ProcessExecutor : IExecutor
|
||||
{
|
||||
/// <summary>Per-stream cap on captured output (excess is dropped and StdoutTruncated set).</summary>
|
||||
@@ -12,23 +16,40 @@ public sealed class ProcessExecutor : IExecutor
|
||||
public async Task<ExecutionResult> RunAsync(EndpointConfig endpoint, ExecutionContext ctx, CancellationToken ct)
|
||||
{
|
||||
var startedAt = DateTimeOffset.UtcNow;
|
||||
var psi = BuildStartInfo(endpoint, ctx);
|
||||
var mode = endpoint.RunAs?.Mode ?? RunAsMode.Service;
|
||||
return mode switch
|
||||
{
|
||||
RunAsMode.InteractiveUser => await RunInteractiveAsync(endpoint, ctx, startedAt, 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;
|
||||
|
||||
if (endpoint.RunAs?.Mode == RunAsMode.SpecificUser)
|
||||
{
|
||||
try { ApplySpecificUser(psi, endpoint.RunAs); }
|
||||
catch (Exception ex) { return Failed(ctx.RunId, startedAt, ex.Message); }
|
||||
}
|
||||
|
||||
using var process = new Process { StartInfo = psi, EnableRaisingEvents = true };
|
||||
|
||||
try
|
||||
{
|
||||
if (!process.Start())
|
||||
{
|
||||
return Failed(ctx.RunId, startedAt, "process failed to start");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Failed(ctx.RunId, startedAt, $"launch error: {ex.Message}");
|
||||
}
|
||||
|
||||
// stdin
|
||||
if (endpoint.DataPassing.StdinJson)
|
||||
{
|
||||
try
|
||||
@@ -42,15 +63,14 @@ public sealed class ProcessExecutor : IExecutor
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { process.StandardInput.Close(); } catch { /* swallow */ }
|
||||
try { process.StandardInput.Close(); } catch { }
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
try { process.StandardInput.Close(); } catch { /* swallow */ }
|
||||
try { process.StandardInput.Close(); } catch { }
|
||||
}
|
||||
|
||||
// Capture stdout/stderr in parallel, with per-stream cap.
|
||||
var stdoutTask = ReadCappedAsync(process.StandardOutput, ct);
|
||||
var stderrTask = ReadCappedAsync(process.StandardError, ct);
|
||||
|
||||
@@ -66,8 +86,8 @@ public sealed class ProcessExecutor : IExecutor
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
timedOut = true;
|
||||
try { process.Kill(entireProcessTree: true); } catch { /* swallow */ }
|
||||
try { await process.WaitForExitAsync(CancellationToken.None).ConfigureAwait(false); } catch { /* swallow */ }
|
||||
try { process.Kill(entireProcessTree: true); } catch { }
|
||||
try { await process.WaitForExitAsync(CancellationToken.None).ConfigureAwait(false); } catch { }
|
||||
}
|
||||
|
||||
var (stdout, stdoutTrunc) = await stdoutTask.ConfigureAwait(false);
|
||||
@@ -87,7 +107,72 @@ public sealed class ProcessExecutor : IExecutor
|
||||
};
|
||||
}
|
||||
|
||||
private static ProcessStartInfo BuildStartInfo(EndpointConfig endpoint, ExecutionContext ctx)
|
||||
// ---------------- Interactive path: launches into the active console session. ----------------
|
||||
|
||||
private static async Task<ExecutionResult> RunInteractiveAsync(EndpointConfig endpoint, ExecutionContext ctx, DateTimeOffset startedAt, CancellationToken ct)
|
||||
{
|
||||
var (psi, envVars) = BuildStartInfo(endpoint, ctx);
|
||||
|
||||
InteractiveProcessLauncher.LaunchResult launch;
|
||||
try
|
||||
{
|
||||
launch = InteractiveProcessLauncher.Launch(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,
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Failed(ctx.RunId, startedAt, $"interactive launch error: {ex.Message}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var stdoutTask = ReadCappedAsync(launch.Stdout, ct);
|
||||
var stderrTask = ReadCappedAsync(launch.Stderr, ct);
|
||||
|
||||
var timeout = TimeSpan.FromSeconds(Math.Max(1, endpoint.TimeoutSeconds));
|
||||
bool timedOut = false;
|
||||
int exitCode = -1;
|
||||
try
|
||||
{
|
||||
exitCode = await InteractiveProcessLauncher.WaitAsync(launch.ProcessHandle, timeout, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
timedOut = true;
|
||||
InteractiveProcessLauncher.Kill(launch.ProcessHandle);
|
||||
}
|
||||
|
||||
var (stdout, stdoutTrunc) = await stdoutTask.ConfigureAwait(false);
|
||||
var (stderr, stderrTrunc) = await stderrTask.ConfigureAwait(false);
|
||||
|
||||
return new ExecutionResult
|
||||
{
|
||||
RunId = ctx.RunId,
|
||||
ExitCode = timedOut ? -1 : exitCode,
|
||||
Stdout = stdout,
|
||||
Stderr = stderr,
|
||||
StdoutTruncated = stdoutTrunc,
|
||||
StderrTruncated = stderrTrunc,
|
||||
StartedAt = startedAt,
|
||||
CompletedAt = DateTimeOffset.UtcNow,
|
||||
TimedOut = timedOut,
|
||||
};
|
||||
}
|
||||
finally
|
||||
{
|
||||
launch.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- Shared psi construction. ----------------
|
||||
|
||||
private static (ProcessStartInfo psi, Dictionary<string, string> envVars) BuildStartInfo(EndpointConfig endpoint, ExecutionContext ctx)
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
@@ -131,18 +216,19 @@ public sealed class ProcessExecutor : IExecutor
|
||||
psi.ArgumentList.Add(arg);
|
||||
}
|
||||
|
||||
var envVars = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["WEBHOOK_RUN_ID"] = ctx.RunId,
|
||||
["WEBHOOK_SLUG"] = ctx.Slug,
|
||||
};
|
||||
|
||||
if (endpoint.DataPassing.EnvVars)
|
||||
{
|
||||
foreach (var (k, v) in ctx.Headers)
|
||||
psi.Environment[$"WEBHOOK_HEADER_{Sanitize(k)}"] = v;
|
||||
foreach (var (k, v) in ctx.Query)
|
||||
psi.Environment[$"WEBHOOK_QUERY_{Sanitize(k)}"] = v;
|
||||
foreach (var (k, v) in ctx.Headers) envVars[$"WEBHOOK_HEADER_{Sanitize(k)}"] = v;
|
||||
foreach (var (k, v) in ctx.Query) envVars[$"WEBHOOK_QUERY_{Sanitize(k)}"] = v;
|
||||
}
|
||||
|
||||
psi.Environment["WEBHOOK_RUN_ID"] = ctx.RunId;
|
||||
psi.Environment["WEBHOOK_SLUG"] = ctx.Slug;
|
||||
|
||||
return psi;
|
||||
return (psi, envVars);
|
||||
}
|
||||
|
||||
private static void AddPwshArgs(ProcessStartInfo psi, EndpointConfig endpoint)
|
||||
@@ -175,6 +261,32 @@ public sealed class ProcessExecutor : IExecutor
|
||||
return endpoint.InlineCommand ?? "";
|
||||
}
|
||||
|
||||
private static void ApplySpecificUser(ProcessStartInfo psi, RunAsConfig runAs)
|
||||
{
|
||||
if (string.IsNullOrEmpty(runAs.Username))
|
||||
throw new InvalidOperationException("RunAs.Username is required when Mode = SpecificUser");
|
||||
if (runAs.Password?.Plaintext is not { Length: > 0 } password)
|
||||
throw new InvalidOperationException("RunAs.Password is required when Mode = SpecificUser");
|
||||
|
||||
var (domain, user) = ParseUserSpec(runAs.Username);
|
||||
psi.UserName = user;
|
||||
if (!string.IsNullOrEmpty(domain)) psi.Domain = domain;
|
||||
|
||||
var ss = new SecureString();
|
||||
foreach (var ch in password) ss.AppendChar(ch);
|
||||
ss.MakeReadOnly();
|
||||
psi.Password = ss;
|
||||
|
||||
psi.LoadUserProfile = runAs.LoadProfile;
|
||||
}
|
||||
|
||||
private static (string Domain, string User) ParseUserSpec(string spec)
|
||||
{
|
||||
var bs = spec.IndexOf('\\');
|
||||
if (bs > 0) return (spec.Substring(0, bs), spec.Substring(bs + 1));
|
||||
return ("", spec);
|
||||
}
|
||||
|
||||
private static string Sanitize(string key)
|
||||
{
|
||||
var sb = new StringBuilder(key.Length);
|
||||
@@ -203,7 +315,6 @@ public sealed class ProcessExecutor : IExecutor
|
||||
catch (IOException) { break; }
|
||||
if (n == 0) break;
|
||||
|
||||
// Cheap byte estimate (ASCII-ish); good enough as a guard rail.
|
||||
if (!truncated)
|
||||
{
|
||||
if (byteEstimate + n > MaxOutputBytes)
|
||||
@@ -218,7 +329,6 @@ public sealed class ProcessExecutor : IExecutor
|
||||
byteEstimate += n;
|
||||
}
|
||||
}
|
||||
// Else keep draining without storing to keep the pipe from blocking.
|
||||
}
|
||||
|
||||
return (sb.ToString(), truncated);
|
||||
|
||||
@@ -42,4 +42,6 @@ public sealed class EndpointConfig
|
||||
public bool Serialize { get; set; }
|
||||
|
||||
public CallbackConfig? Callback { get; set; }
|
||||
|
||||
public RunAsConfig? RunAs { get; set; }
|
||||
}
|
||||
|
||||
@@ -53,3 +53,18 @@ public enum HttpsBindingKind
|
||||
PfxFile = 1,
|
||||
CertStoreThumbprint = 2,
|
||||
}
|
||||
|
||||
public enum RunAsMode
|
||||
{
|
||||
/// <summary>Run as whatever account the service itself runs under (default).</summary>
|
||||
Service = 0,
|
||||
|
||||
/// <summary>Run as a specific username + password (batch logon, no UI).</summary>
|
||||
SpecificUser = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Run in the active console session under whoever is logged in at the keyboard.
|
||||
/// Lets hooks pop interactive UI on the user's desktop.
|
||||
/// </summary>
|
||||
InteractiveUser = 2,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace WebhookServer.Core.Models;
|
||||
|
||||
public sealed class RunAsConfig
|
||||
{
|
||||
public RunAsMode Mode { get; set; } = RunAsMode.Service;
|
||||
|
||||
/// <summary>
|
||||
/// "DOMAIN\user" or "user@upn" or just "user" (local). Required when
|
||||
/// <see cref="Mode"/> is <see cref="RunAsMode.SpecificUser"/>.
|
||||
/// </summary>
|
||||
public string? Username { get; set; }
|
||||
|
||||
/// <summary>DPAPI-protected password for SpecificUser mode.</summary>
|
||||
public ProtectedString? Password { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When true, load the user's profile (HKCU + AppData) before running.
|
||||
/// Slower; only needed for hooks that read user-scope settings.
|
||||
/// </summary>
|
||||
public bool LoadProfile { get; set; }
|
||||
}
|
||||
@@ -55,6 +55,7 @@ public sealed class ConfigStore
|
||||
{
|
||||
ClearOne(ep.Bearer?.Secret);
|
||||
ClearOne(ep.Hmac?.Secret);
|
||||
ClearOne(ep.RunAs?.Password);
|
||||
if (ep.Callback is { } cb)
|
||||
{
|
||||
ClearOne(cb.Bearer?.Secret);
|
||||
@@ -76,6 +77,7 @@ public sealed class ConfigStore
|
||||
{
|
||||
DecryptOne(ep.Bearer?.Secret);
|
||||
DecryptOne(ep.Hmac?.Secret);
|
||||
DecryptOne(ep.RunAs?.Password);
|
||||
if (ep.Callback is { } cb)
|
||||
{
|
||||
DecryptOne(cb.Bearer?.Secret);
|
||||
@@ -91,6 +93,7 @@ public sealed class ConfigStore
|
||||
{
|
||||
EncryptOne(ep.Bearer?.Secret);
|
||||
EncryptOne(ep.Hmac?.Secret);
|
||||
EncryptOne(ep.RunAs?.Password);
|
||||
if (ep.Callback is { } cb)
|
||||
{
|
||||
EncryptOne(cb.Bearer?.Secret);
|
||||
|
||||
Reference in New Issue
Block a user