diff options
Diffstat (limited to 'src/Agent.Admin/Agent.Admin.Client')
3 files changed, 305 insertions, 0 deletions
diff --git a/src/Agent.Admin/Agent.Admin.Client/AdminProtocol.cs b/src/Agent.Admin/Agent.Admin.Client/AdminProtocol.cs new file mode 100644 index 0000000..679ccab --- /dev/null +++ b/src/Agent.Admin/Agent.Admin.Client/AdminProtocol.cs @@ -0,0 +1,61 @@ +using System.Buffers.Binary; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Agent.Admin.Client; + +public sealed record AdminNetworkEndpoint(string Address, int Port); + +public sealed record AdminRequest( + int Version, + string Operation, + Guid TaskId, + string? ApplicationSid = null, + string? NetworkAccess = null, + IReadOnlyList<AdminNetworkEndpoint>? Endpoints = null, + bool? OriginallyLoopbackExempt = null); + +public sealed record AdminResponse(bool Success, string? ErrorCode = null, string? Message = null, bool? LoopbackExempt = null); + +public static class AdminProtocol +{ + public const int Version = 1; + public const int MaxMessageBytes = 64 * 1024; + private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web) + { + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow + }; + + public static async Task WriteAsync<T>(Stream stream, T value, CancellationToken cancellationToken) + { + byte[] payload = JsonSerializer.SerializeToUtf8Bytes(value, Json); + if (payload.Length is <= 0 or > MaxMessageBytes) throw new InvalidDataException("Administrative pipe message exceeds the fixed size limit."); + byte[] header = new byte[sizeof(int)]; + BinaryPrimitives.WriteInt32LittleEndian(header, payload.Length); + await stream.WriteAsync(header, cancellationToken).ConfigureAwait(false); + await stream.WriteAsync(payload, cancellationToken).ConfigureAwait(false); + await stream.FlushAsync(cancellationToken).ConfigureAwait(false); + } + + public static async Task<T> ReadAsync<T>(Stream stream, CancellationToken cancellationToken) + { + byte[] header = new byte[sizeof(int)]; + await ReadExactlyAsync(stream, header, cancellationToken).ConfigureAwait(false); + int length = BinaryPrimitives.ReadInt32LittleEndian(header); + if (length is <= 0 or > MaxMessageBytes) throw new InvalidDataException("Administrative pipe message has an invalid size."); + byte[] payload = new byte[length]; + await ReadExactlyAsync(stream, payload, cancellationToken).ConfigureAwait(false); + return JsonSerializer.Deserialize<T>(payload, Json) ?? throw new InvalidDataException("Administrative pipe message is empty."); + } + + private static async Task ReadExactlyAsync(Stream stream, Memory<byte> buffer, CancellationToken cancellationToken) + { + int offset = 0; + while (offset < buffer.Length) + { + int read = await stream.ReadAsync(buffer[offset..], cancellationToken).ConfigureAwait(false); + if (read == 0) throw new EndOfStreamException("Administrative pipe disconnected."); + offset += read; + } + } +} diff --git a/src/Agent.Admin/Agent.Admin.Client/Agent.Admin.Client.csproj b/src/Agent.Admin/Agent.Admin.Client/Agent.Admin.Client.csproj new file mode 100644 index 0000000..019c9d4 --- /dev/null +++ b/src/Agent.Admin/Agent.Admin.Client/Agent.Admin.Client.csproj @@ -0,0 +1,7 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <TargetFramework>net10.0-windows10.0.26100.0</TargetFramework> + <AssemblyName>Agent.Admin.Client</AssemblyName> + <AllowUnsafeBlocks>true</AllowUnsafeBlocks> + </PropertyGroup> +</Project> diff --git a/src/Agent.Admin/Agent.Admin.Client/ElevatedNetworkSession.cs b/src/Agent.Admin/Agent.Admin.Client/ElevatedNetworkSession.cs new file mode 100644 index 0000000..57339dc --- /dev/null +++ b/src/Agent.Admin/Agent.Admin.Client/ElevatedNetworkSession.cs @@ -0,0 +1,237 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.IO.Pipes; +using System.Runtime.InteropServices; +using System.Security.AccessControl; +using System.Security.Principal; +using Microsoft.Win32.SafeHandles; + +namespace Agent.Admin.Client; + +public sealed class AdminOperationException(string code, string message, Exception? inner = null) : Exception(message, inner) +{ + public string Code { get; } = code; +} + +public interface IElevatedNetworkSession : IAsyncDisposable +{ + Task<bool> InspectLoopbackAsync(string applicationSid, CancellationToken cancellationToken); + Task ApplyAsync(string applicationSid, string networkAccess, IReadOnlyList<AdminNetworkEndpoint> endpoints, + bool originallyLoopbackExempt, CancellationToken cancellationToken); + Task RecoverLoopbackAsync(string applicationSid, bool originallyLoopbackExempt, CancellationToken cancellationToken); + Task CleanupAsync(CancellationToken cancellationToken); +} + +public interface IElevatedNetworkSessionFactory +{ + Task<IElevatedNetworkSession> StartAsync(Guid taskId, CancellationToken cancellationToken); +} + +public sealed partial class ElevatedNetworkSessionFactory(string helperPath) : IElevatedNetworkSessionFactory +{ + private const uint PipeAccessDuplex = 0x00000003; + private const uint FileFlagOverlapped = 0x40000000; + private const uint FileFlagWriteThrough = 0x80000000; + private const uint PipeRejectRemoteClients = 0x00000008; + + public async Task<IElevatedNetworkSession> StartAsync(Guid taskId, CancellationToken cancellationToken) + { + string canonicalHelper = Path.GetFullPath(helperPath); + if (!File.Exists(canonicalHelper)) + throw new AdminOperationException("sandbox.helper_incompatible", $"Elevated helper does not exist at the fixed Bundle path '{canonicalHelper}'."); + + string pipeName = $"AgentSandboxMcp-{Environment.ProcessId}-{Guid.NewGuid():N}"; + using WindowsIdentity identity = WindowsIdentity.GetCurrent(); + SecurityIdentifier logon = GetLogonSid(identity); + var security = new PipeSecurity(); + security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false); + security.AddAccessRule(new PipeAccessRule(logon, PipeAccessRights.ReadWrite, AccessControlType.Allow)); + security.AddAccessRule(new PipeAccessRule(new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null), + PipeAccessRights.ReadWrite, AccessControlType.Allow)); + security.AddAccessRule(new PipeAccessRule(new SecurityIdentifier(WellKnownSidType.NetworkSid, null), + PipeAccessRights.FullControl, AccessControlType.Deny)); + + NamedPipeServerStream pipe = CreateLocalPipe(pipeName, security); + Process? helper = null; + try + { + var start = new ProcessStartInfo + { + FileName = canonicalHelper, + Arguments = $"--pipe {Quote(pipeName)} --parent-pid {Environment.ProcessId} --task {taskId:N} --protocol {AdminProtocol.Version}", + UseShellExecute = true, + Verb = "runas", + WorkingDirectory = Path.GetDirectoryName(canonicalHelper)! + }; + try { helper = Process.Start(start); } + catch (Win32Exception ex) when (ex.NativeErrorCode == 1223) + { + throw new AdminOperationException("sandbox.elevation_cancelled", "The Windows elevation request was cancelled.", ex); + } + catch (Exception ex) when (ex is Win32Exception or InvalidOperationException) + { + throw new AdminOperationException("sandbox.elevation_failed", $"The elevated helper could not be started: {ex.Message}", ex); + } + if (helper is null) throw new AdminOperationException("sandbox.elevation_failed", "The elevated helper did not return a Process handle."); + + using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + Task connection = pipe.WaitForConnectionAsync(linked.Token); + Task exit = helper.WaitForExitAsync(linked.Token); + Task timeout = Task.Delay(TimeSpan.FromSeconds(30), linked.Token); + Task completed = await Task.WhenAny(connection, exit, timeout).ConfigureAwait(false); + if (completed == exit) + throw new AdminOperationException("sandbox.elevation_failed", $"The elevated helper exited before connecting (exit code {helper.ExitCode})."); + if (completed == timeout) + throw new AdminOperationException("sandbox.elevation_failed", "The elevated helper did not connect within 30 seconds."); + await connection.ConfigureAwait(false); + linked.Cancel(); + + if (!Native.GetNamedPipeClientProcessId(pipe.SafePipeHandle, out uint clientPid) || clientPid != helper.Id || helper.HasExited) + throw new AdminOperationException("sandbox.ipc_rejected", "The Named Pipe peer does not match the elevated Process started for this Task."); + await AdminProtocol.WriteAsync(pipe, new AdminRequest(AdminProtocol.Version, "hello", taskId), cancellationToken).ConfigureAwait(false); + AdminResponse hello = await AdminProtocol.ReadAsync<AdminResponse>(pipe, cancellationToken).ConfigureAwait(false); + EnsureSuccess(hello); + return new ElevatedNetworkSession(pipe, helper, taskId); + } + catch + { + pipe.Dispose(); + helper?.Dispose(); + throw; + } + } + + private static string Quote(string value) => '"' + value.Replace("\"", "\\\"") + '"'; + + private static SecurityIdentifier GetLogonSid(WindowsIdentity identity) + { + const int tokenLogonSid = 28; + _ = Native.GetTokenInformation(identity.Token, tokenLogonSid, 0, 0, out uint length); + if (length < (uint)(IntPtr.Size * 2)) + throw new AdminOperationException("sandbox.ipc_rejected", "The invoking logon SID size is invalid."); + nint buffer = Marshal.AllocHGlobal(checked((int)length)); + try + { + if (!Native.GetTokenInformation(identity.Token, tokenLogonSid, buffer, length, out _)) + throw new AdminOperationException("sandbox.ipc_rejected", + $"The invoking logon SID is unavailable: {new Win32Exception(Marshal.GetLastPInvokeError()).Message}"); + // TOKEN_GROUPS contains a 32-bit count followed by pointer-aligned SID_AND_ATTRIBUTES. + nint sid = Marshal.ReadIntPtr(buffer, IntPtr.Size); + return new SecurityIdentifier(sid); + } + finally { Marshal.FreeHGlobal(buffer); } + } + + private static NamedPipeServerStream CreateLocalPipe(string pipeName, PipeSecurity security) + { + byte[] descriptor = security.GetSecurityDescriptorBinaryForm(); + GCHandle pinned = GCHandle.Alloc(descriptor, GCHandleType.Pinned); + try + { + var attributes = new Native.SecurityAttributes + { + Length = Marshal.SizeOf<Native.SecurityAttributes>(), + SecurityDescriptor = pinned.AddrOfPinnedObject(), + InheritHandle = 0 + }; + SafePipeHandle handle = Native.CreateNamedPipe($@"\\.\pipe\{pipeName}", + PipeAccessDuplex | FileFlagOverlapped | FileFlagWriteThrough, + PipeRejectRemoteClients, 1, 4096, 4096, 0, in attributes); + if (handle.IsInvalid) + { + int error = Marshal.GetLastPInvokeError(); + handle.Dispose(); + throw new AdminOperationException("sandbox.ipc_rejected", + $"The local administrative Named Pipe could not be created: {new Win32Exception(error).Message}"); + } + return new NamedPipeServerStream(PipeDirection.InOut, isAsync: true, isConnected: false, handle); + } + finally { pinned.Free(); } + } + + internal static void EnsureSuccess(AdminResponse response) + { + if (!response.Success) throw new AdminOperationException(response.ErrorCode ?? "sandbox.network_apply_failed", + response.Message ?? "The elevated network operation failed."); + } + + private static partial class Native + { + [StructLayout(LayoutKind.Sequential)] + internal struct SecurityAttributes + { + internal int Length; + internal nint SecurityDescriptor; + internal int InheritHandle; + } + + [LibraryImport("kernel32.dll", EntryPoint = "CreateNamedPipeW", SetLastError = true, + StringMarshalling = StringMarshalling.Utf16)] + internal static partial SafePipeHandle CreateNamedPipe(string name, uint openMode, uint pipeMode, + uint maxInstances, uint outBufferSize, uint inBufferSize, uint defaultTimeout, + in SecurityAttributes securityAttributes); + + [LibraryImport("advapi32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool GetTokenInformation(nint tokenHandle, int tokenInformationClass, + nint tokenInformation, uint tokenInformationLength, out uint returnLength); + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool GetNamedPipeClientProcessId(SafePipeHandle pipe, out uint clientProcessId); + } +} + +internal sealed class ElevatedNetworkSession(NamedPipeServerStream pipe, Process helper, Guid taskId) : IElevatedNetworkSession +{ + private bool _cleaned; + + public async Task<bool> InspectLoopbackAsync(string applicationSid, CancellationToken cancellationToken) + { + AdminResponse response = await SendAsync(new(AdminProtocol.Version, "inspect-loopback", taskId, applicationSid), cancellationToken).ConfigureAwait(false); + return response.LoopbackExempt ?? throw new AdminOperationException("sandbox.ipc_rejected", "The elevated helper omitted the loopback state."); + } + + public async Task ApplyAsync(string applicationSid, string networkAccess, IReadOnlyList<AdminNetworkEndpoint> endpoints, + bool originallyLoopbackExempt, CancellationToken cancellationToken) => + ElevatedNetworkSessionFactory.EnsureSuccess(await SendAsync(new(AdminProtocol.Version, "apply-network", taskId, + applicationSid, networkAccess, endpoints, originallyLoopbackExempt), cancellationToken).ConfigureAwait(false)); + + public async Task RecoverLoopbackAsync(string applicationSid, bool originallyLoopbackExempt, CancellationToken cancellationToken) => + ElevatedNetworkSessionFactory.EnsureSuccess(await SendAsync(new(AdminProtocol.Version, "recover-loopback", taskId, + applicationSid, OriginallyLoopbackExempt: originallyLoopbackExempt), cancellationToken).ConfigureAwait(false)); + + public async Task CleanupAsync(CancellationToken cancellationToken) + { + if (_cleaned) return; + ElevatedNetworkSessionFactory.EnsureSuccess(await SendAsync(new(AdminProtocol.Version, "cleanup-network", taskId), cancellationToken).ConfigureAwait(false)); + _cleaned = true; + } + + private async Task<AdminResponse> SendAsync(AdminRequest request, CancellationToken cancellationToken) + { + if (helper.HasExited) throw new AdminOperationException("sandbox.elevation_failed", "The elevated helper exited before Task Resource cleanup."); + await AdminProtocol.WriteAsync(pipe, request, cancellationToken).ConfigureAwait(false); + return await AdminProtocol.ReadAsync<AdminResponse>(pipe, cancellationToken).ConfigureAwait(false); + } + + public async ValueTask DisposeAsync() + { + try + { + if (pipe.IsConnected) + { + if (!_cleaned) await CleanupAsync(CancellationToken.None).ConfigureAwait(false); + await AdminProtocol.WriteAsync(pipe, new AdminRequest(AdminProtocol.Version, "shutdown", taskId), CancellationToken.None).ConfigureAwait(false); + } + } + catch { } + pipe.Dispose(); + try + { + if (!helper.HasExited) await helper.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + } + catch { if (!helper.HasExited) helper.Kill(entireProcessTree: true); } + helper.Dispose(); + } +} |
