diff --git a/src/WebhookServer.Core/Execution/Native/InteractiveProcessLauncher.cs b/src/WebhookServer.Core/Execution/Native/InteractiveProcessLauncher.cs index 41275bc..09ce6c5 100644 --- a/src/WebhookServer.Core/Execution/Native/InteractiveProcessLauncher.cs +++ b/src/WebhookServer.Core/Execution/Native/InteractiveProcessLauncher.cs @@ -43,7 +43,12 @@ internal static class InteractiveProcessLauncher } } - public static LaunchResult Launch(LaunchOptions opts) + /// + /// 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) @@ -52,6 +57,44 @@ internal static class InteractiveProcessLauncher if (!WTSQueryUserToken(sessionId, out var userToken)) throw LastError("WTSQueryUserToken (must run as SYSTEM)"); + try { return LaunchWithToken(userToken, opts); } + 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); } + 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) + { IntPtr primaryToken = IntPtr.Zero; IntPtr envBlock = IntPtr.Zero; IntPtr stdoutRead = IntPtr.Zero, stdoutWrite = IntPtr.Zero; @@ -62,7 +105,7 @@ internal static class InteractiveProcessLauncher try { - if (!DuplicateTokenEx(userToken, (uint)MAXIMUM_ALLOWED, IntPtr.Zero, + if (!DuplicateTokenEx(sourceToken, (uint)MAXIMUM_ALLOWED, IntPtr.Zero, SECURITY_IMPERSONATION_LEVEL.SecurityImpersonation, TOKEN_TYPE.TokenPrimary, out primaryToken)) throw LastError("DuplicateTokenEx"); @@ -140,7 +183,6 @@ internal static class InteractiveProcessLauncher } finally { - if (userToken != IntPtr.Zero) CloseHandle(userToken); if (primaryToken != IntPtr.Zero) CloseHandle(primaryToken); if (envBlock != IntPtr.Zero) DestroyEnvironmentBlock(envBlock); if (!succeeded) diff --git a/src/WebhookServer.Core/Execution/Native/NativeMethods.cs b/src/WebhookServer.Core/Execution/Native/NativeMethods.cs index b809674..ae52b72 100644 --- a/src/WebhookServer.Core/Execution/Native/NativeMethods.cs +++ b/src/WebhookServer.Core/Execution/Native/NativeMethods.cs @@ -79,6 +79,19 @@ internal static class NativeMethods public uint dwThreadId; } + public const int LOGON32_LOGON_INTERACTIVE = 2; + public const int LOGON32_LOGON_BATCH = 4; + public const int LOGON32_PROVIDER_DEFAULT = 0; + + [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + public static extern bool LogonUser( + string lpszUsername, + string? lpszDomain, + string lpszPassword, + int dwLogonType, + int dwLogonProvider, + out IntPtr phToken); + [DllImport("kernel32.dll")] public static extern uint WTSGetActiveConsoleSessionId(); diff --git a/src/WebhookServer.Core/Execution/ProcessExecutor.cs b/src/WebhookServer.Core/Execution/ProcessExecutor.cs index d627669..3d29aff 100644 --- a/src/WebhookServer.Core/Execution/ProcessExecutor.cs +++ b/src/WebhookServer.Core/Execution/ProcessExecutor.cs @@ -1,6 +1,5 @@ using System.Diagnostics; using System.Runtime.Versioning; -using System.Security; using System.Text; using WebhookServer.Core.Execution.Native; using WebhookServer.Core.Models; @@ -19,7 +18,8 @@ public sealed class ProcessExecutor : IExecutor var mode = endpoint.RunAs?.Mode ?? RunAsMode.Service; return mode switch { - RunAsMode.InteractiveUser => await RunInteractiveAsync(endpoint, ctx, startedAt, ct).ConfigureAwait(false), + RunAsMode.InteractiveUser => await RunWithLauncherAsync(endpoint, ctx, startedAt, useActiveConsole: true, ct).ConfigureAwait(false), + RunAsMode.SpecificUser => await RunWithLauncherAsync(endpoint, ctx, startedAt, useActiveConsole: false, ct).ConfigureAwait(false), _ => await RunWithProcessAsync(endpoint, ctx, startedAt, ct).ConfigureAwait(false), }; } @@ -32,12 +32,6 @@ public sealed class ProcessExecutor : IExecutor 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 @@ -107,27 +101,42 @@ public sealed class ProcessExecutor : IExecutor }; } - // ---------------- Interactive path: launches into the active console session. ---------------- + // ---------------- Token-based path: InteractiveUser + SpecificUser. ---------------- - private static async Task RunInteractiveAsync(EndpointConfig endpoint, ExecutionContext ctx, DateTimeOffset startedAt, CancellationToken ct) + private static async Task RunWithLauncherAsync(EndpointConfig endpoint, ExecutionContext ctx, DateTimeOffset startedAt, bool useActiveConsole, CancellationToken ct) { var (psi, envVars) = BuildStartInfo(endpoint, ctx); + var opts = new InteractiveProcessLauncher.LaunchOptions + { + FileName = psi.FileName, + Arguments = psi.ArgumentList.ToList(), + WorkingDirectory = string.IsNullOrEmpty(psi.WorkingDirectory) ? null : psi.WorkingDirectory, + ExtraEnvVars = envVars, + StdinBytes = endpoint.DataPassing.StdinJson ? ctx.BodyBytes : null, + }; InteractiveProcessLauncher.LaunchResult launch; try { - launch = InteractiveProcessLauncher.Launch(new InteractiveProcessLauncher.LaunchOptions + if (useActiveConsole) { - FileName = psi.FileName, - Arguments = psi.ArgumentList.ToList(), - WorkingDirectory = string.IsNullOrEmpty(psi.WorkingDirectory) ? null : psi.WorkingDirectory, - ExtraEnvVars = envVars, - StdinBytes = endpoint.DataPassing.StdinJson ? ctx.BodyBytes : null, - }); + launch = InteractiveProcessLauncher.LaunchAsActiveConsoleUser(opts); + } + else + { + var runAs = endpoint.RunAs ?? throw new InvalidOperationException("RunAs config missing"); + if (string.IsNullOrEmpty(runAs.Username)) + return Failed(ctx.RunId, startedAt, "RunAs.Username is required when Mode = SpecificUser"); + if (runAs.Password?.Plaintext is not { Length: > 0 } password) + return Failed(ctx.RunId, startedAt, "RunAs.Password is required when Mode = SpecificUser"); + + var (domain, user) = ParseUserSpec(runAs.Username); + launch = InteractiveProcessLauncher.LaunchAsSpecificUser(user, password, domain, opts); + } } catch (Exception ex) { - return Failed(ctx.RunId, startedAt, $"interactive launch error: {ex.Message}"); + return Failed(ctx.RunId, startedAt, $"launch error: {ex.Message}"); } try @@ -261,25 +270,6 @@ 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('\\');