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('\\');