Document service account choices for AD-aware hooks #1
@@ -43,7 +43,12 @@ internal static class InteractiveProcessLauncher
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static LaunchResult Launch(LaunchOptions opts)
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
public static LaunchResult LaunchAsActiveConsoleUser(LaunchOptions opts)
|
||||||
{
|
{
|
||||||
var sessionId = WTSGetActiveConsoleSessionId();
|
var sessionId = WTSGetActiveConsoleSessionId();
|
||||||
if (sessionId == INVALID_SESSION_ID)
|
if (sessionId == INVALID_SESSION_ID)
|
||||||
@@ -52,6 +57,44 @@ internal static class InteractiveProcessLauncher
|
|||||||
if (!WTSQueryUserToken(sessionId, out var userToken))
|
if (!WTSQueryUserToken(sessionId, out var userToken))
|
||||||
throw LastError("WTSQueryUserToken (must run as SYSTEM)");
|
throw LastError("WTSQueryUserToken (must run as SYSTEM)");
|
||||||
|
|
||||||
|
try { return LaunchWithToken(userToken, opts); }
|
||||||
|
finally { CloseHandle(userToken); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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 primaryToken = IntPtr.Zero;
|
||||||
IntPtr envBlock = IntPtr.Zero;
|
IntPtr envBlock = IntPtr.Zero;
|
||||||
IntPtr stdoutRead = IntPtr.Zero, stdoutWrite = IntPtr.Zero;
|
IntPtr stdoutRead = IntPtr.Zero, stdoutWrite = IntPtr.Zero;
|
||||||
@@ -62,7 +105,7 @@ internal static class InteractiveProcessLauncher
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (!DuplicateTokenEx(userToken, (uint)MAXIMUM_ALLOWED, IntPtr.Zero,
|
if (!DuplicateTokenEx(sourceToken, (uint)MAXIMUM_ALLOWED, IntPtr.Zero,
|
||||||
SECURITY_IMPERSONATION_LEVEL.SecurityImpersonation,
|
SECURITY_IMPERSONATION_LEVEL.SecurityImpersonation,
|
||||||
TOKEN_TYPE.TokenPrimary, out primaryToken))
|
TOKEN_TYPE.TokenPrimary, out primaryToken))
|
||||||
throw LastError("DuplicateTokenEx");
|
throw LastError("DuplicateTokenEx");
|
||||||
@@ -140,7 +183,6 @@ internal static class InteractiveProcessLauncher
|
|||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
if (userToken != IntPtr.Zero) CloseHandle(userToken);
|
|
||||||
if (primaryToken != IntPtr.Zero) CloseHandle(primaryToken);
|
if (primaryToken != IntPtr.Zero) CloseHandle(primaryToken);
|
||||||
if (envBlock != IntPtr.Zero) DestroyEnvironmentBlock(envBlock);
|
if (envBlock != IntPtr.Zero) DestroyEnvironmentBlock(envBlock);
|
||||||
if (!succeeded)
|
if (!succeeded)
|
||||||
|
|||||||
@@ -79,6 +79,19 @@ internal static class NativeMethods
|
|||||||
public uint dwThreadId;
|
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")]
|
[DllImport("kernel32.dll")]
|
||||||
public static extern uint WTSGetActiveConsoleSessionId();
|
public static extern uint WTSGetActiveConsoleSessionId();
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Runtime.Versioning;
|
using System.Runtime.Versioning;
|
||||||
using System.Security;
|
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using WebhookServer.Core.Execution.Native;
|
using WebhookServer.Core.Execution.Native;
|
||||||
using WebhookServer.Core.Models;
|
using WebhookServer.Core.Models;
|
||||||
@@ -19,7 +18,8 @@ public sealed class ProcessExecutor : IExecutor
|
|||||||
var mode = endpoint.RunAs?.Mode ?? RunAsMode.Service;
|
var mode = endpoint.RunAs?.Mode ?? RunAsMode.Service;
|
||||||
return mode switch
|
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),
|
_ => await RunWithProcessAsync(endpoint, ctx, startedAt, ct).ConfigureAwait(false),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -32,12 +32,6 @@ public sealed class ProcessExecutor : IExecutor
|
|||||||
foreach (var (k, v) in envVars)
|
foreach (var (k, v) in envVars)
|
||||||
psi.Environment[k] = v;
|
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
|
||||||
@@ -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<ExecutionResult> RunInteractiveAsync(EndpointConfig endpoint, ExecutionContext ctx, DateTimeOffset startedAt, CancellationToken ct)
|
private static async Task<ExecutionResult> RunWithLauncherAsync(EndpointConfig endpoint, ExecutionContext ctx, DateTimeOffset startedAt, bool useActiveConsole, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var (psi, envVars) = BuildStartInfo(endpoint, ctx);
|
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;
|
InteractiveProcessLauncher.LaunchResult launch;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
launch = InteractiveProcessLauncher.Launch(new InteractiveProcessLauncher.LaunchOptions
|
if (useActiveConsole)
|
||||||
{
|
{
|
||||||
FileName = psi.FileName,
|
launch = InteractiveProcessLauncher.LaunchAsActiveConsoleUser(opts);
|
||||||
Arguments = psi.ArgumentList.ToList(),
|
}
|
||||||
WorkingDirectory = string.IsNullOrEmpty(psi.WorkingDirectory) ? null : psi.WorkingDirectory,
|
else
|
||||||
ExtraEnvVars = envVars,
|
{
|
||||||
StdinBytes = endpoint.DataPassing.StdinJson ? ctx.BodyBytes : null,
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
return Failed(ctx.RunId, startedAt, $"interactive launch error: {ex.Message}");
|
return Failed(ctx.RunId, startedAt, $"launch error: {ex.Message}");
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -261,25 +270,6 @@ 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)
|
private static (string Domain, string User) ParseUserSpec(string spec)
|
||||||
{
|
{
|
||||||
var bs = spec.IndexOf('\\');
|
var bs = spec.IndexOf('\\');
|
||||||
|
|||||||
Reference in New Issue
Block a user