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 { }
}
}
///
/// Launch into the session of whoever is logged in at the keyboard. Lets hooks
/// pop UI on the user's desktop. Caller must be SYSTEM (only SYSTEM can call
/// WTSQueryUserToken).
///
public static LaunchResult LaunchAsActiveConsoleUser(LaunchOptions opts)
{
var sessionId = WTSGetActiveConsoleSessionId();
if (sessionId == INVALID_SESSION_ID)
throw new InvalidOperationException("No active console session - is anyone logged in at the keyboard?");
if (!WTSQueryUserToken(sessionId, out var userToken))
throw LastError("WTSQueryUserToken (must run as SYSTEM)");
try { return LaunchWithToken(userToken, opts, useInteractiveDesktop: true); }
finally { CloseHandle(userToken); }
}
///
/// Launch under a username/password by calling LogonUser to obtain a token.
/// Used instead of psi.UserName/Password because CreateProcessWithLogonW (what
/// .NET uses under the hood) refuses to run when the caller is SYSTEM.
/// Tries interactive logon first, then batch.
///
public static LaunchResult LaunchAsSpecificUser(string username, string password, string? domain, LaunchOptions opts)
{
var resolvedDomain = NormalizeDomain(domain);
IntPtr token;
if (!LogonUser(username, resolvedDomain, password, LOGON32_LOGON_INTERACTIVE, LOGON32_PROVIDER_DEFAULT, out token))
{
if (!LogonUser(username, resolvedDomain, password, LOGON32_LOGON_BATCH, LOGON32_PROVIDER_DEFAULT, out token))
{
var who = string.IsNullOrEmpty(domain) ? username : $"{domain}\\{username}";
throw LastError($"LogonUser ({who})");
}
}
try { return LaunchWithToken(token, opts, useInteractiveDesktop: false); }
finally { CloseHandle(token); }
}
private static string? NormalizeDomain(string? domain)
{
if (string.IsNullOrEmpty(domain)) return null;
// "." is a common shorthand for "this machine"; LogonUser wants the actual
// machine name or null for local accounts.
if (domain == ".") return Environment.MachineName;
return domain;
}
private static LaunchResult LaunchWithToken(IntPtr sourceToken, LaunchOptions opts, bool useInteractiveDesktop)
{
IntPtr primaryToken = IntPtr.Zero;
IntPtr envBlock = IntPtr.Zero;
IntPtr stdoutRead = IntPtr.Zero, stdoutWrite = IntPtr.Zero;
IntPtr stderrRead = IntPtr.Zero, stderrWrite = IntPtr.Zero;
IntPtr stdinRead = IntPtr.Zero, stdinWrite = IntPtr.Zero;
var pi = new PROCESS_INFORMATION();
bool succeeded = false;
try
{
if (!DuplicateTokenEx(sourceToken, (uint)MAXIMUM_ALLOWED, IntPtr.Zero,
SECURITY_IMPERSONATION_LEVEL.SecurityImpersonation,
TOKEN_TYPE.TokenPrimary, out primaryToken))
throw LastError("DuplicateTokenEx");
if (!CreateEnvironmentBlock(out envBlock, primaryToken, false))
throw LastError("CreateEnvironmentBlock");
if (opts.ExtraEnvVars is { Count: > 0 })
envBlock = AppendEnvVars(envBlock, opts.ExtraEnvVars);
CreateInheritablePipe(out stdoutRead, out stdoutWrite, parentReads: true);
CreateInheritablePipe(out stderrRead, out stderrWrite, parentReads: true);
CreateInheritablePipe(out stdinRead, out stdinWrite, parentReads: false);
var si = new STARTUPINFO
{
cb = Marshal.SizeOf(),
dwFlags = STARTF_USESTDHANDLES,
hStdInput = stdinRead,
hStdOutput = stdoutWrite,
hStdError = stderrWrite,
// For InteractiveUser we explicitly target the logged-in user's desktop.
// For SpecificUser the LogonUser-derived token typically can't open that
// DACL; leave lpDesktop null and let the new process inherit ours.
lpDesktop = useInteractiveDesktop ? @"winsta0\default" : null,
};
var commandLine = BuildCommandLine(opts.FileName, opts.Arguments);
if (!CreateProcessAsUser(
primaryToken,
null,
commandLine,
IntPtr.Zero, IntPtr.Zero,
bInheritHandles: true,
CREATE_UNICODE_ENVIRONMENT | CREATE_NO_WINDOW | NORMAL_PRIORITY_CLASS,
envBlock,
string.IsNullOrEmpty(opts.WorkingDirectory) ? null : opts.WorkingDirectory,
ref si,
out pi))
{
throw LastError("CreateProcessAsUser");
}
// Close child-side handles in our process so EOF propagates when the child exits.
CloseHandle(stdoutWrite); stdoutWrite = IntPtr.Zero;
CloseHandle(stderrWrite); stderrWrite = IntPtr.Zero;
CloseHandle(stdinRead); stdinRead = IntPtr.Zero;
// Pipe stdin if provided, then close the write end.
if (opts.StdinBytes is { Length: > 0 })
{
using var stdinStream = new FileStream(new SafeFileHandle(stdinWrite, ownsHandle: true), FileAccess.Write);
stdinStream.Write(opts.StdinBytes, 0, opts.StdinBytes.Length);
stdinStream.Flush();
stdinWrite = IntPtr.Zero; // ownership transferred to the FileStream
}
else
{
CloseHandle(stdinWrite); stdinWrite = IntPtr.Zero;
}
CloseHandle(pi.hThread);
var stdout = new StreamReader(new FileStream(new SafeFileHandle(stdoutRead, true), FileAccess.Read), Encoding.UTF8);
var stderr = new StreamReader(new FileStream(new SafeFileHandle(stderrRead, true), FileAccess.Read), Encoding.UTF8);
stdoutRead = IntPtr.Zero;
stderrRead = IntPtr.Zero;
succeeded = true;
return new LaunchResult
{
ProcessHandle = pi.hProcess,
ProcessId = pi.dwProcessId,
Stdout = stdout,
Stderr = stderr,
};
}
finally
{
if (primaryToken != IntPtr.Zero) CloseHandle(primaryToken);
if (envBlock != IntPtr.Zero) DestroyEnvironmentBlock(envBlock);
if (!succeeded)
{
if (stdoutRead != IntPtr.Zero) CloseHandle(stdoutRead);
if (stdoutWrite != IntPtr.Zero) CloseHandle(stdoutWrite);
if (stderrRead != IntPtr.Zero) CloseHandle(stderrRead);
if (stderrWrite != IntPtr.Zero) CloseHandle(stderrWrite);
if (stdinRead != IntPtr.Zero) CloseHandle(stdinRead);
if (stdinWrite != IntPtr.Zero) CloseHandle(stdinWrite);
if (pi.hProcess != IntPtr.Zero) CloseHandle(pi.hProcess);
if (pi.hThread != IntPtr.Zero) CloseHandle(pi.hThread);
}
}
}
public static async Task 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('"');
}
}