diff --git a/src/WebhookServer.Core/Execution/Native/InteractiveProcessLauncher.cs b/src/WebhookServer.Core/Execution/Native/InteractiveProcessLauncher.cs new file mode 100644 index 0000000..41275bc --- /dev/null +++ b/src/WebhookServer.Core/Execution/Native/InteractiveProcessLauncher.cs @@ -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; + +/// +/// 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 Process.Start 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 WTSQueryUserToken. +/// +[SupportedOSPlatform("windows")] +internal static class InteractiveProcessLauncher +{ + public sealed class LaunchOptions + { + public required string FileName { get; init; } + public required IReadOnlyList Arguments { get; init; } + public string? WorkingDirectory { get; init; } + public IReadOnlyDictionary? 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(), + 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 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(), + 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 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(); + 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(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; + } + + /// + /// Build a Windows command line string from a filename + arg list using the + /// quoting rules consumed by CommandLineToArgvW. + /// + private static string BuildCommandLine(string fileName, IReadOnlyList 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('"'); + } +} diff --git a/src/WebhookServer.Core/Execution/Native/NativeMethods.cs b/src/WebhookServer.Core/Execution/Native/NativeMethods.cs new file mode 100644 index 0000000..b809674 --- /dev/null +++ b/src/WebhookServer.Core/Execution/Native/NativeMethods.cs @@ -0,0 +1,144 @@ +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; + +namespace WebhookServer.Core.Execution.Native; + +/// +/// Win32 P/Invoke layer for launching processes in another user's session. +/// Used by ; not intended for general use. +/// +[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"); +} diff --git a/src/WebhookServer.Core/Execution/ProcessExecutor.cs b/src/WebhookServer.Core/Execution/ProcessExecutor.cs index ad8d6c5..d627669 100644 --- a/src/WebhookServer.Core/Execution/ProcessExecutor.cs +++ b/src/WebhookServer.Core/Execution/ProcessExecutor.cs @@ -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 { /// Per-stream cap on captured output (excess is dropped and StdoutTruncated set). @@ -12,23 +16,40 @@ public sealed class ProcessExecutor : IExecutor public async Task 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 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 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 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(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); diff --git a/src/WebhookServer.Core/Models/EndpointConfig.cs b/src/WebhookServer.Core/Models/EndpointConfig.cs index 87c0f7b..3c9b415 100644 --- a/src/WebhookServer.Core/Models/EndpointConfig.cs +++ b/src/WebhookServer.Core/Models/EndpointConfig.cs @@ -42,4 +42,6 @@ public sealed class EndpointConfig public bool Serialize { get; set; } public CallbackConfig? Callback { get; set; } + + public RunAsConfig? RunAs { get; set; } } diff --git a/src/WebhookServer.Core/Models/Enums.cs b/src/WebhookServer.Core/Models/Enums.cs index 3fd094b..429c8bf 100644 --- a/src/WebhookServer.Core/Models/Enums.cs +++ b/src/WebhookServer.Core/Models/Enums.cs @@ -53,3 +53,18 @@ public enum HttpsBindingKind PfxFile = 1, CertStoreThumbprint = 2, } + +public enum RunAsMode +{ + /// Run as whatever account the service itself runs under (default). + Service = 0, + + /// Run as a specific username + password (batch logon, no UI). + SpecificUser = 1, + + /// + /// Run in the active console session under whoever is logged in at the keyboard. + /// Lets hooks pop interactive UI on the user's desktop. + /// + InteractiveUser = 2, +} diff --git a/src/WebhookServer.Core/Models/RunAsConfig.cs b/src/WebhookServer.Core/Models/RunAsConfig.cs new file mode 100644 index 0000000..47d648d --- /dev/null +++ b/src/WebhookServer.Core/Models/RunAsConfig.cs @@ -0,0 +1,21 @@ +namespace WebhookServer.Core.Models; + +public sealed class RunAsConfig +{ + public RunAsMode Mode { get; set; } = RunAsMode.Service; + + /// + /// "DOMAIN\user" or "user@upn" or just "user" (local). Required when + /// is . + /// + public string? Username { get; set; } + + /// DPAPI-protected password for SpecificUser mode. + public ProtectedString? Password { get; set; } + + /// + /// When true, load the user's profile (HKCU + AppData) before running. + /// Slower; only needed for hooks that read user-scope settings. + /// + public bool LoadProfile { get; set; } +} diff --git a/src/WebhookServer.Core/Storage/ConfigStore.cs b/src/WebhookServer.Core/Storage/ConfigStore.cs index e83aaf6..7e8b9a4 100644 --- a/src/WebhookServer.Core/Storage/ConfigStore.cs +++ b/src/WebhookServer.Core/Storage/ConfigStore.cs @@ -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); diff --git a/src/WebhookServer.Gui/ViewModels/EndpointEditorViewModel.cs b/src/WebhookServer.Gui/ViewModels/EndpointEditorViewModel.cs index dd63d0b..bafcd7a 100644 --- a/src/WebhookServer.Gui/ViewModels/EndpointEditorViewModel.cs +++ b/src/WebhookServer.Gui/ViewModels/EndpointEditorViewModel.cs @@ -23,6 +23,8 @@ public sealed partial class EndpointEditorViewModel : ObservableObject Endpoint = JsonSerializer.Deserialize(json, ConfigJson.Compact)!; Endpoint.Bearer ??= new BearerOptions(); Endpoint.Hmac ??= new HmacOptions(); + Endpoint.RunAs ??= new RunAsConfig(); + Endpoint.RunAs.Password ??= new ProtectedString(); IsNew = isNew; } @@ -53,6 +55,58 @@ public sealed partial class EndpointEditorViewModel : ObservableObject public Visibility HmacVisible => Endpoint.AuthMode == AuthMode.Hmac ? Visibility.Visible : Visibility.Collapsed; + public Array RunAsModes { get; } = Enum.GetValues(typeof(RunAsMode)); + + public RunAsMode SelectedRunAsMode + { + get => Endpoint.RunAs?.Mode ?? RunAsMode.Service; + set + { + Endpoint.RunAs ??= new RunAsConfig(); + if (Endpoint.RunAs.Mode == value) return; + Endpoint.RunAs.Mode = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(SpecificUserVisible)); + } + } + + public Visibility SpecificUserVisible => + SelectedRunAsMode == RunAsMode.SpecificUser ? Visibility.Visible : Visibility.Collapsed; + + public string RunAsUsername + { + get => Endpoint.RunAs?.Username ?? ""; + set + { + Endpoint.RunAs ??= new RunAsConfig(); + Endpoint.RunAs.Username = string.IsNullOrEmpty(value) ? null : value; + OnPropertyChanged(); + } + } + + public string RunAsPassword + { + get => Endpoint.RunAs?.Password?.Plaintext ?? ""; + set + { + Endpoint.RunAs ??= new RunAsConfig(); + Endpoint.RunAs.Password ??= new ProtectedString(); + Endpoint.RunAs.Password.Plaintext = string.IsNullOrEmpty(value) ? null : value; + OnPropertyChanged(); + } + } + + public bool RunAsLoadProfile + { + get => Endpoint.RunAs?.LoadProfile ?? false; + set + { + Endpoint.RunAs ??= new RunAsConfig(); + Endpoint.RunAs.LoadProfile = value; + OnPropertyChanged(); + } + } + public string AllowedClientsText { get => string.Join(Environment.NewLine, Endpoint.AllowedClients); diff --git a/src/WebhookServer.Gui/Views/EndpointEditor.xaml b/src/WebhookServer.Gui/Views/EndpointEditor.xaml index 9afce5b..0d3ef95 100644 --- a/src/WebhookServer.Gui/Views/EndpointEditor.xaml +++ b/src/WebhookServer.Gui/Views/EndpointEditor.xaml @@ -130,6 +130,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/WebhookServer.Service/AdminPipeServer.cs b/src/WebhookServer.Service/AdminPipeServer.cs index 780b4e9..2e7d25b 100644 --- a/src/WebhookServer.Service/AdminPipeServer.cs +++ b/src/WebhookServer.Service/AdminPipeServer.cs @@ -255,6 +255,7 @@ internal sealed class AdminPipeServer : BackgroundService { if (incoming.Bearer is { } a) MergeProtected(a.Secret, prior.Bearer?.Secret); if (incoming.Hmac is { } h) MergeProtected(h.Secret, prior.Hmac?.Secret); + if (incoming.RunAs is { Password: { } runAsPwd }) MergeProtected(runAsPwd, prior.RunAs?.Password); if (incoming.Callback is { } cb) { if (cb.Bearer is { } cba) MergeProtected(cba.Secret, prior.Callback?.Bearer?.Secret);