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.Diagnostics;
|
||||||
|
using System.Runtime.Versioning;
|
||||||
|
using System.Security;
|
||||||
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 +16,40 @@ 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 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 };
|
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 +63,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 +86,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 +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
|
var psi = new ProcessStartInfo
|
||||||
{
|
{
|
||||||
@@ -131,18 +216,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 +261,32 @@ public sealed class ProcessExecutor : IExecutor
|
|||||||
return endpoint.InlineCommand ?? "";
|
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)
|
private static string Sanitize(string key)
|
||||||
{
|
{
|
||||||
var sb = new StringBuilder(key.Length);
|
var sb = new StringBuilder(key.Length);
|
||||||
@@ -203,7 +315,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 +329,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);
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -23,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,6 +55,58 @@ public sealed partial class EndpointEditorViewModel : ObservableObject
|
|||||||
public Visibility HmacVisible =>
|
public Visibility HmacVisible =>
|
||||||
Endpoint.AuthMode == AuthMode.Hmac ? Visibility.Visible : Visibility.Collapsed;
|
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);
|
||||||
|
|||||||
@@ -130,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>
|
||||||
|
|||||||
@@ -255,6 +255,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);
|
||||||
|
|||||||
Reference in New Issue
Block a user