From d0bd2092e754b2dc30c9d945cbc0c1d89097fb83 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Thu, 20 Oct 2022 13:16:32 +0800 Subject: [PATCH 1/2] Experiment with named pipes transport --- AspNetCore.sln | 38 ++ eng/ProjectReferences.props | 1 + eng/SharedFramework.Local.props | 1 + eng/TrimmableProjects.props | 1 + src/Framework/test/TestData.cs | 2 + src/Http/Http/src/BindingAddress.cs | 47 ++- src/Http/Http/src/PublicAPI.Unshipped.txt | 2 + .../Features/IConnectionNamedPipeFeature.cs | 17 + .../src/NamedPipeEndPoint.cs | 65 +++ .../PublicAPI/net462/PublicAPI.Unshipped.txt | 10 + .../PublicAPI/net8.0/PublicAPI.Unshipped.txt | 10 + .../netstandard2.0/PublicAPI.Unshipped.txt | 10 + .../netstandard2.1/PublicAPI.Unshipped.txt | 10 + .../Core/src/Internal/AddressBinder.cs | 4 + .../Core/src/Internal/KestrelServerImpl.cs | 5 - .../Kestrel/Core/src/KestrelServerOptions.cs | 34 +- src/Servers/Kestrel/Core/src/ListenOptions.cs | 37 +- .../Core/src/Properties/AssemblyInfo.cs | 1 + .../Kestrel/Core/src/PublicAPI.Unshipped.txt | 3 + .../Kestrel/Core/test/AddressBinderTests.cs | 44 ++ .../Kestrel/Core/test/KestrelServerTests.cs | 2 +- src/Servers/Kestrel/Kestrel.slnf | 2 + ...Microsoft.AspNetCore.Server.Kestrel.csproj | 1 + .../src/WebHostBuilderKestrelExtensions.cs | 18 +- .../WebHostBuilderKestrelExtensionsTests.cs | 20 +- .../src/Internal/NamedPipeConnection.cs | 293 +++++++++++++ .../Internal/NamedPipeConnectionListener.cs | 181 +++++++++ .../src/Internal/NamedPipeLog.cs | 80 ++++ .../src/Internal/NamedPipeTransportFactory.cs | 64 +++ ...Server.Kestrel.Transport.NamedPipes.csproj | 37 ++ .../src/NamedPipeTransportOptions.cs | 56 +++ .../src/PublicAPI.Shipped.txt | 1 + .../src/PublicAPI.Unshipped.txt | 14 + .../src/WebHostBuilderNamedPipeExtensions.cs | 43 ++ ....Kestrel.Transport.NamedPipes.Tests.csproj | 31 ++ .../test/NamedPipeConnectionListenerTests.cs | 166 ++++++++ .../test/NamedPipeConnectionTests.cs | 106 +++++ .../test/NamedPipeTestHelpers.cs | 90 ++++ .../Transport.NamedPipes/test/WebHostTests.cs | 384 ++++++++++++++++++ .../test/TransportTestHelpers/TestServer.cs | 2 +- 40 files changed, 1894 insertions(+), 39 deletions(-) create mode 100644 src/Servers/Connections.Abstractions/src/Features/IConnectionNamedPipeFeature.cs create mode 100644 src/Servers/Connections.Abstractions/src/NamedPipeEndPoint.cs create mode 100644 src/Servers/Kestrel/Transport.NamedPipes/src/Internal/NamedPipeConnection.cs create mode 100644 src/Servers/Kestrel/Transport.NamedPipes/src/Internal/NamedPipeConnectionListener.cs create mode 100644 src/Servers/Kestrel/Transport.NamedPipes/src/Internal/NamedPipeLog.cs create mode 100644 src/Servers/Kestrel/Transport.NamedPipes/src/Internal/NamedPipeTransportFactory.cs create mode 100644 src/Servers/Kestrel/Transport.NamedPipes/src/Microsoft.AspNetCore.Server.Kestrel.Transport.NamedPipes.csproj create mode 100644 src/Servers/Kestrel/Transport.NamedPipes/src/NamedPipeTransportOptions.cs create mode 100644 src/Servers/Kestrel/Transport.NamedPipes/src/PublicAPI.Shipped.txt create mode 100644 src/Servers/Kestrel/Transport.NamedPipes/src/PublicAPI.Unshipped.txt create mode 100644 src/Servers/Kestrel/Transport.NamedPipes/src/WebHostBuilderNamedPipeExtensions.cs create mode 100644 src/Servers/Kestrel/Transport.NamedPipes/test/Microsoft.AspNetCore.Server.Kestrel.Transport.NamedPipes.Tests.csproj create mode 100644 src/Servers/Kestrel/Transport.NamedPipes/test/NamedPipeConnectionListenerTests.cs create mode 100644 src/Servers/Kestrel/Transport.NamedPipes/test/NamedPipeConnectionTests.cs create mode 100644 src/Servers/Kestrel/Transport.NamedPipes/test/NamedPipeTestHelpers.cs create mode 100644 src/Servers/Kestrel/Transport.NamedPipes/test/WebHostTests.cs diff --git a/AspNetCore.sln b/AspNetCore.sln index 3559078a5cda..4cb6d6f24eb4 100644 --- a/AspNetCore.sln +++ b/AspNetCore.sln @@ -1756,6 +1756,12 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "perf", "perf", "{74377D3E-E EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Server.HttpSys.Microbenchmarks", "src\Servers\HttpSys\perf\Microbenchmarks\Microsoft.AspNetCore.Server.HttpSys.Microbenchmarks.csproj", "{3C7C65BF-0C13-418E-90BD-EC9C3CD282CB}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Transport.NamedPipes", "Transport.NamedPipes", "{F057512B-55BF-4A8B-A027-A0505F8BA10C}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Server.Kestrel.Transport.NamedPipes", "src\Servers\Kestrel\Transport.NamedPipes\src\Microsoft.AspNetCore.Server.Kestrel.Transport.NamedPipes.csproj", "{10173568-A65E-44E5-8C6F-4AA49D0577A1}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Server.Kestrel.Transport.NamedPipes.Tests", "src\Servers\Kestrel\Transport.NamedPipes\test\Microsoft.AspNetCore.Server.Kestrel.Transport.NamedPipes.Tests.csproj", "{97C7D2A4-87E5-4A4A-A170-D736427D5C21}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -10533,6 +10539,38 @@ Global {3C7C65BF-0C13-418E-90BD-EC9C3CD282CB}.Release|x64.Build.0 = Release|Any CPU {3C7C65BF-0C13-418E-90BD-EC9C3CD282CB}.Release|x86.ActiveCfg = Release|Any CPU {3C7C65BF-0C13-418E-90BD-EC9C3CD282CB}.Release|x86.Build.0 = Release|Any CPU + {10173568-A65E-44E5-8C6F-4AA49D0577A1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {10173568-A65E-44E5-8C6F-4AA49D0577A1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {10173568-A65E-44E5-8C6F-4AA49D0577A1}.Debug|arm64.ActiveCfg = Debug|Any CPU + {10173568-A65E-44E5-8C6F-4AA49D0577A1}.Debug|arm64.Build.0 = Debug|Any CPU + {10173568-A65E-44E5-8C6F-4AA49D0577A1}.Debug|x64.ActiveCfg = Debug|Any CPU + {10173568-A65E-44E5-8C6F-4AA49D0577A1}.Debug|x64.Build.0 = Debug|Any CPU + {10173568-A65E-44E5-8C6F-4AA49D0577A1}.Debug|x86.ActiveCfg = Debug|Any CPU + {10173568-A65E-44E5-8C6F-4AA49D0577A1}.Debug|x86.Build.0 = Debug|Any CPU + {10173568-A65E-44E5-8C6F-4AA49D0577A1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {10173568-A65E-44E5-8C6F-4AA49D0577A1}.Release|Any CPU.Build.0 = Release|Any CPU + {10173568-A65E-44E5-8C6F-4AA49D0577A1}.Release|arm64.ActiveCfg = Release|Any CPU + {10173568-A65E-44E5-8C6F-4AA49D0577A1}.Release|arm64.Build.0 = Release|Any CPU + {10173568-A65E-44E5-8C6F-4AA49D0577A1}.Release|x64.ActiveCfg = Release|Any CPU + {10173568-A65E-44E5-8C6F-4AA49D0577A1}.Release|x64.Build.0 = Release|Any CPU + {10173568-A65E-44E5-8C6F-4AA49D0577A1}.Release|x86.ActiveCfg = Release|Any CPU + {10173568-A65E-44E5-8C6F-4AA49D0577A1}.Release|x86.Build.0 = Release|Any CPU + {97C7D2A4-87E5-4A4A-A170-D736427D5C21}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {97C7D2A4-87E5-4A4A-A170-D736427D5C21}.Debug|Any CPU.Build.0 = Debug|Any CPU + {97C7D2A4-87E5-4A4A-A170-D736427D5C21}.Debug|arm64.ActiveCfg = Debug|Any CPU + {97C7D2A4-87E5-4A4A-A170-D736427D5C21}.Debug|arm64.Build.0 = Debug|Any CPU + {97C7D2A4-87E5-4A4A-A170-D736427D5C21}.Debug|x64.ActiveCfg = Debug|Any CPU + {97C7D2A4-87E5-4A4A-A170-D736427D5C21}.Debug|x64.Build.0 = Debug|Any CPU + {97C7D2A4-87E5-4A4A-A170-D736427D5C21}.Debug|x86.ActiveCfg = Debug|Any CPU + {97C7D2A4-87E5-4A4A-A170-D736427D5C21}.Debug|x86.Build.0 = Debug|Any CPU + {97C7D2A4-87E5-4A4A-A170-D736427D5C21}.Release|Any CPU.ActiveCfg = Release|Any CPU + {97C7D2A4-87E5-4A4A-A170-D736427D5C21}.Release|Any CPU.Build.0 = Release|Any CPU + {97C7D2A4-87E5-4A4A-A170-D736427D5C21}.Release|arm64.ActiveCfg = Release|Any CPU + {97C7D2A4-87E5-4A4A-A170-D736427D5C21}.Release|arm64.Build.0 = Release|Any CPU + {97C7D2A4-87E5-4A4A-A170-D736427D5C21}.Release|x64.ActiveCfg = Release|Any CPU + {97C7D2A4-87E5-4A4A-A170-D736427D5C21}.Release|x64.Build.0 = Release|Any CPU + {97C7D2A4-87E5-4A4A-A170-D736427D5C21}.Release|x86.ActiveCfg = Release|Any CPU + {97C7D2A4-87E5-4A4A-A170-D736427D5C21}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/eng/ProjectReferences.props b/eng/ProjectReferences.props index 9643ae81d3a7..34e46979bfb2 100644 --- a/eng/ProjectReferences.props +++ b/eng/ProjectReferences.props @@ -50,6 +50,7 @@ + diff --git a/eng/SharedFramework.Local.props b/eng/SharedFramework.Local.props index 986c5e4ef4db..822ae1740c91 100644 --- a/eng/SharedFramework.Local.props +++ b/eng/SharedFramework.Local.props @@ -60,6 +60,7 @@ + diff --git a/eng/TrimmableProjects.props b/eng/TrimmableProjects.props index 50a1520c5757..ffde1bd5b9bb 100644 --- a/eng/TrimmableProjects.props +++ b/eng/TrimmableProjects.props @@ -40,6 +40,7 @@ + diff --git a/src/Framework/test/TestData.cs b/src/Framework/test/TestData.cs index 2763c05a5048..dc0de73f0652 100644 --- a/src/Framework/test/TestData.cs +++ b/src/Framework/test/TestData.cs @@ -90,6 +90,7 @@ static TestData() "Microsoft.AspNetCore.Server.Kestrel", "Microsoft.AspNetCore.Server.Kestrel.Core", "Microsoft.AspNetCore.Server.Kestrel.Transport.Quic", + "Microsoft.AspNetCore.Server.Kestrel.Transport.NamedPipes", "Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets", "Microsoft.AspNetCore.Session", "Microsoft.AspNetCore.SignalR", @@ -228,6 +229,7 @@ static TestData() { "Microsoft.AspNetCore.Server.Kestrel.Core" }, { "Microsoft.AspNetCore.Server.Kestrel.Transport.Quic" }, { "Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets" }, + { "Microsoft.AspNetCore.Server.Kestrel.Transport.NamedPipes" }, { "Microsoft.AspNetCore.Server.Kestrel" }, { "Microsoft.AspNetCore.Session" }, { "Microsoft.AspNetCore.SignalR.Common" }, diff --git a/src/Http/Http/src/BindingAddress.cs b/src/Http/Http/src/BindingAddress.cs index 398489c84dd0..bc44ae270188 100644 --- a/src/Http/Http/src/BindingAddress.cs +++ b/src/Http/Http/src/BindingAddress.cs @@ -11,6 +11,7 @@ namespace Microsoft.AspNetCore.Http; public class BindingAddress { private const string UnixPipeHostPrefix = "unix:/"; + private const string NamedPipeHostPrefix = "pipe:/"; private BindingAddress(string host, string pathBase, int port, string scheme) { @@ -57,6 +58,14 @@ public BindingAddress() /// public bool IsUnixPipe => Host.StartsWith(UnixPipeHostPrefix, StringComparison.Ordinal); + /// + /// Gets a value that determines if this instance represents a named pipe. + /// + /// Returns if starts with pipe:/ prefix. + /// + /// + public bool IsNamedPipe => Host.StartsWith(NamedPipeHostPrefix, StringComparison.Ordinal); + /// /// Gets the unix pipe path if this instance represents a Unix pipe. /// @@ -73,6 +82,22 @@ public string UnixPipePath } } + /// + /// Gets the named pipe name if this instance represents a named pipe. + /// + public string NamedPipeName + { + get + { + if (!IsNamedPipe) + { + throw new InvalidOperationException("Binding address is not a named pipe."); + } + + return GetNamedPipeName(Host); + } + } + private static string GetUnixPipePath(string host) { var unixPipeHostPrefixLength = UnixPipeHostPrefix.Length; @@ -84,10 +109,12 @@ private static string GetUnixPipePath(string host) return host.Substring(unixPipeHostPrefixLength); } + private static string GetNamedPipeName(string host) => host.Substring(NamedPipeHostPrefix.Length); + /// public override string ToString() { - if (IsUnixPipe) + if (IsUnixPipe || IsNamedPipe) { return Scheme.ToLowerInvariant() + Uri.SchemeDelimiter + Host.ToLowerInvariant(); } @@ -135,15 +162,11 @@ public static BindingAddress Parse(string address) var schemeDelimiterEnd = schemeDelimiterStart + Uri.SchemeDelimiter.Length; var isUnixPipe = address.IndexOf(UnixPipeHostPrefix, schemeDelimiterEnd, StringComparison.Ordinal) == schemeDelimiterEnd; + var isNamedPipe = address.IndexOf(NamedPipeHostPrefix, schemeDelimiterEnd, StringComparison.Ordinal) == schemeDelimiterEnd; int pathDelimiterStart; int pathDelimiterEnd; - if (!isUnixPipe) - { - pathDelimiterStart = address.IndexOf("/", schemeDelimiterEnd, StringComparison.Ordinal); - pathDelimiterEnd = pathDelimiterStart; - } - else + if (isUnixPipe) { var unixPipeHostPrefixLength = UnixPipeHostPrefix.Length; if (OperatingSystem.IsWindows()) @@ -159,6 +182,16 @@ public static BindingAddress Parse(string address) pathDelimiterStart = address.IndexOf(":", schemeDelimiterEnd + unixPipeHostPrefixLength, StringComparison.Ordinal); pathDelimiterEnd = pathDelimiterStart + ":".Length; } + else if (isNamedPipe) + { + pathDelimiterStart = address.IndexOf(":", schemeDelimiterEnd + NamedPipeHostPrefix.Length, StringComparison.Ordinal); + pathDelimiterEnd = pathDelimiterStart + ":".Length; + } + else + { + pathDelimiterStart = address.IndexOf("/", schemeDelimiterEnd, StringComparison.Ordinal); + pathDelimiterEnd = pathDelimiterStart; + } if (pathDelimiterStart < 0) { diff --git a/src/Http/Http/src/PublicAPI.Unshipped.txt b/src/Http/Http/src/PublicAPI.Unshipped.txt index a158cc48ca22..57e2d5acac44 100644 --- a/src/Http/Http/src/PublicAPI.Unshipped.txt +++ b/src/Http/Http/src/PublicAPI.Unshipped.txt @@ -1,3 +1,5 @@ #nullable enable *REMOVED*Microsoft.AspNetCore.Http.StreamResponseBodyFeature.StreamResponseBodyFeature(System.IO.Stream! stream, Microsoft.AspNetCore.Http.Features.IHttpResponseBodyFeature! priorFeature) -> void +Microsoft.AspNetCore.Http.BindingAddress.IsNamedPipe.get -> bool +Microsoft.AspNetCore.Http.BindingAddress.NamedPipeName.get -> string! Microsoft.AspNetCore.Http.StreamResponseBodyFeature.StreamResponseBodyFeature(System.IO.Stream! stream, Microsoft.AspNetCore.Http.Features.IHttpResponseBodyFeature? priorFeature) -> void diff --git a/src/Servers/Connections.Abstractions/src/Features/IConnectionNamedPipeFeature.cs b/src/Servers/Connections.Abstractions/src/Features/IConnectionNamedPipeFeature.cs new file mode 100644 index 000000000000..1c54f74390f5 --- /dev/null +++ b/src/Servers/Connections.Abstractions/src/Features/IConnectionNamedPipeFeature.cs @@ -0,0 +1,17 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.IO.Pipes; + +namespace Microsoft.AspNetCore.Connections.Features; + +/// +/// Provides access to the connection's underlying . +/// +public interface IConnectionNamedPipeFeature +{ + /// + /// Gets the underlying . + /// + NamedPipeServerStream NamedPipe { get; } +} diff --git a/src/Servers/Connections.Abstractions/src/NamedPipeEndPoint.cs b/src/Servers/Connections.Abstractions/src/NamedPipeEndPoint.cs new file mode 100644 index 000000000000..52d2d803e888 --- /dev/null +++ b/src/Servers/Connections.Abstractions/src/NamedPipeEndPoint.cs @@ -0,0 +1,65 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using System.Net; + +namespace Microsoft.AspNetCore.Connections; + +/// +/// Represents a Named Pipe endpoint. +/// +public sealed class NamedPipeEndPoint : EndPoint +{ + internal const string LocalComputerServerName = "."; + + /// + /// Initializes a new instance of the class. + /// + /// The name of the pipe. + public NamedPipeEndPoint(string pipeName) : this(pipeName, LocalComputerServerName) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The name of the pipe. + /// The name of the remote computer to connect to, or "." to specify the local computer. + public NamedPipeEndPoint(string pipeName, string serverName) + { + ServerName = serverName; + PipeName = pipeName; + } + + /// + /// Gets the name of the remote computer. The server name must be ".", the local computer, when creating a server. + /// + public string ServerName { get; } + + /// + /// Gets the name of the pipe. + /// + public string PipeName { get; } + + /// + /// Gets the pipe name represented by this instance. + /// + public override string ToString() + { + // Based on format at https://learn.microsoft.com/windows/win32/ipc/pipe-names + return $@"\\{ServerName}\pipe\{PipeName}"; + } + + /// + public override bool Equals([NotNullWhen(true)] object? obj) + { + return obj is NamedPipeEndPoint other && other.ServerName == ServerName && other.PipeName == PipeName; + } + + /// + public override int GetHashCode() + { + return ServerName.GetHashCode() ^ PipeName.GetHashCode(); + } +} diff --git a/src/Servers/Connections.Abstractions/src/PublicAPI/net462/PublicAPI.Unshipped.txt b/src/Servers/Connections.Abstractions/src/PublicAPI/net462/PublicAPI.Unshipped.txt index 88184eb7b651..d4e8ab74f791 100644 --- a/src/Servers/Connections.Abstractions/src/PublicAPI/net462/PublicAPI.Unshipped.txt +++ b/src/Servers/Connections.Abstractions/src/PublicAPI/net462/PublicAPI.Unshipped.txt @@ -1,5 +1,15 @@ #nullable enable +Microsoft.AspNetCore.Connections.Features.IConnectionNamedPipeFeature +Microsoft.AspNetCore.Connections.Features.IConnectionNamedPipeFeature.NamedPipe.get -> System.IO.Pipes.NamedPipeServerStream! Microsoft.AspNetCore.Connections.Features.IStreamClosedFeature Microsoft.AspNetCore.Connections.Features.IStreamClosedFeature.OnClosed(System.Action! callback, object? state) -> void Microsoft.AspNetCore.Connections.IConnectionListenerFactorySelector Microsoft.AspNetCore.Connections.IConnectionListenerFactorySelector.CanBind(System.Net.EndPoint! endpoint) -> bool +Microsoft.AspNetCore.Connections.NamedPipeEndPoint +Microsoft.AspNetCore.Connections.NamedPipeEndPoint.NamedPipeEndPoint(string! pipeName) -> void +Microsoft.AspNetCore.Connections.NamedPipeEndPoint.NamedPipeEndPoint(string! pipeName, string! serverName) -> void +Microsoft.AspNetCore.Connections.NamedPipeEndPoint.PipeName.get -> string! +Microsoft.AspNetCore.Connections.NamedPipeEndPoint.ServerName.get -> string! +override Microsoft.AspNetCore.Connections.NamedPipeEndPoint.Equals(object? obj) -> bool +override Microsoft.AspNetCore.Connections.NamedPipeEndPoint.GetHashCode() -> int +override Microsoft.AspNetCore.Connections.NamedPipeEndPoint.ToString() -> string! diff --git a/src/Servers/Connections.Abstractions/src/PublicAPI/net8.0/PublicAPI.Unshipped.txt b/src/Servers/Connections.Abstractions/src/PublicAPI/net8.0/PublicAPI.Unshipped.txt index aa5fad13eb1f..8c4a9bb452fa 100644 --- a/src/Servers/Connections.Abstractions/src/PublicAPI/net8.0/PublicAPI.Unshipped.txt +++ b/src/Servers/Connections.Abstractions/src/PublicAPI/net8.0/PublicAPI.Unshipped.txt @@ -1,8 +1,18 @@ #nullable enable +Microsoft.AspNetCore.Connections.Features.IConnectionNamedPipeFeature +Microsoft.AspNetCore.Connections.Features.IConnectionNamedPipeFeature.NamedPipe.get -> System.IO.Pipes.NamedPipeServerStream! Microsoft.AspNetCore.Connections.Features.IStreamClosedFeature Microsoft.AspNetCore.Connections.Features.IStreamClosedFeature.OnClosed(System.Action! callback, object? state) -> void Microsoft.AspNetCore.Connections.IConnectionListenerFactorySelector Microsoft.AspNetCore.Connections.IConnectionListenerFactorySelector.CanBind(System.Net.EndPoint! endpoint) -> bool +Microsoft.AspNetCore.Connections.NamedPipeEndPoint +Microsoft.AspNetCore.Connections.NamedPipeEndPoint.NamedPipeEndPoint(string! pipeName) -> void +Microsoft.AspNetCore.Connections.NamedPipeEndPoint.NamedPipeEndPoint(string! pipeName, string! serverName) -> void +Microsoft.AspNetCore.Connections.NamedPipeEndPoint.PipeName.get -> string! +Microsoft.AspNetCore.Connections.NamedPipeEndPoint.ServerName.get -> string! +override Microsoft.AspNetCore.Connections.NamedPipeEndPoint.Equals(object? obj) -> bool +override Microsoft.AspNetCore.Connections.NamedPipeEndPoint.GetHashCode() -> int +override Microsoft.AspNetCore.Connections.NamedPipeEndPoint.ToString() -> string! Microsoft.AspNetCore.Connections.TlsConnectionCallbackContext Microsoft.AspNetCore.Connections.TlsConnectionCallbackContext.ClientHelloInfo.get -> System.Net.Security.SslClientHelloInfo Microsoft.AspNetCore.Connections.TlsConnectionCallbackContext.ClientHelloInfo.set -> void diff --git a/src/Servers/Connections.Abstractions/src/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt b/src/Servers/Connections.Abstractions/src/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt index 88184eb7b651..d4e8ab74f791 100644 --- a/src/Servers/Connections.Abstractions/src/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt +++ b/src/Servers/Connections.Abstractions/src/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt @@ -1,5 +1,15 @@ #nullable enable +Microsoft.AspNetCore.Connections.Features.IConnectionNamedPipeFeature +Microsoft.AspNetCore.Connections.Features.IConnectionNamedPipeFeature.NamedPipe.get -> System.IO.Pipes.NamedPipeServerStream! Microsoft.AspNetCore.Connections.Features.IStreamClosedFeature Microsoft.AspNetCore.Connections.Features.IStreamClosedFeature.OnClosed(System.Action! callback, object? state) -> void Microsoft.AspNetCore.Connections.IConnectionListenerFactorySelector Microsoft.AspNetCore.Connections.IConnectionListenerFactorySelector.CanBind(System.Net.EndPoint! endpoint) -> bool +Microsoft.AspNetCore.Connections.NamedPipeEndPoint +Microsoft.AspNetCore.Connections.NamedPipeEndPoint.NamedPipeEndPoint(string! pipeName) -> void +Microsoft.AspNetCore.Connections.NamedPipeEndPoint.NamedPipeEndPoint(string! pipeName, string! serverName) -> void +Microsoft.AspNetCore.Connections.NamedPipeEndPoint.PipeName.get -> string! +Microsoft.AspNetCore.Connections.NamedPipeEndPoint.ServerName.get -> string! +override Microsoft.AspNetCore.Connections.NamedPipeEndPoint.Equals(object? obj) -> bool +override Microsoft.AspNetCore.Connections.NamedPipeEndPoint.GetHashCode() -> int +override Microsoft.AspNetCore.Connections.NamedPipeEndPoint.ToString() -> string! diff --git a/src/Servers/Connections.Abstractions/src/PublicAPI/netstandard2.1/PublicAPI.Unshipped.txt b/src/Servers/Connections.Abstractions/src/PublicAPI/netstandard2.1/PublicAPI.Unshipped.txt index 88184eb7b651..d4e8ab74f791 100644 --- a/src/Servers/Connections.Abstractions/src/PublicAPI/netstandard2.1/PublicAPI.Unshipped.txt +++ b/src/Servers/Connections.Abstractions/src/PublicAPI/netstandard2.1/PublicAPI.Unshipped.txt @@ -1,5 +1,15 @@ #nullable enable +Microsoft.AspNetCore.Connections.Features.IConnectionNamedPipeFeature +Microsoft.AspNetCore.Connections.Features.IConnectionNamedPipeFeature.NamedPipe.get -> System.IO.Pipes.NamedPipeServerStream! Microsoft.AspNetCore.Connections.Features.IStreamClosedFeature Microsoft.AspNetCore.Connections.Features.IStreamClosedFeature.OnClosed(System.Action! callback, object? state) -> void Microsoft.AspNetCore.Connections.IConnectionListenerFactorySelector Microsoft.AspNetCore.Connections.IConnectionListenerFactorySelector.CanBind(System.Net.EndPoint! endpoint) -> bool +Microsoft.AspNetCore.Connections.NamedPipeEndPoint +Microsoft.AspNetCore.Connections.NamedPipeEndPoint.NamedPipeEndPoint(string! pipeName) -> void +Microsoft.AspNetCore.Connections.NamedPipeEndPoint.NamedPipeEndPoint(string! pipeName, string! serverName) -> void +Microsoft.AspNetCore.Connections.NamedPipeEndPoint.PipeName.get -> string! +Microsoft.AspNetCore.Connections.NamedPipeEndPoint.ServerName.get -> string! +override Microsoft.AspNetCore.Connections.NamedPipeEndPoint.Equals(object? obj) -> bool +override Microsoft.AspNetCore.Connections.NamedPipeEndPoint.GetHashCode() -> int +override Microsoft.AspNetCore.Connections.NamedPipeEndPoint.ToString() -> string! diff --git a/src/Servers/Kestrel/Core/src/Internal/AddressBinder.cs b/src/Servers/Kestrel/Core/src/Internal/AddressBinder.cs index e8f8bbee90d4..6f5b48723872 100644 --- a/src/Servers/Kestrel/Core/src/Internal/AddressBinder.cs +++ b/src/Servers/Kestrel/Core/src/Internal/AddressBinder.cs @@ -120,6 +120,10 @@ internal static ListenOptions ParseAddress(string address, out bool https) { options = new ListenOptions(parsedAddress.UnixPipePath); } + else if (parsedAddress.IsNamedPipe) + { + options = new ListenOptions(new NamedPipeEndPoint(parsedAddress.NamedPipeName)); + } else if (string.Equals(parsedAddress.Host, "localhost", StringComparison.OrdinalIgnoreCase)) { // "localhost" for both IPv4 and IPv6 can't be represented as an IPEndPoint. diff --git a/src/Servers/Kestrel/Core/src/Internal/KestrelServerImpl.cs b/src/Servers/Kestrel/Core/src/Internal/KestrelServerImpl.cs index 2232c7f60f32..f2cc083d5c39 100644 --- a/src/Servers/Kestrel/Core/src/Internal/KestrelServerImpl.cs +++ b/src/Servers/Kestrel/Core/src/Internal/KestrelServerImpl.cs @@ -60,12 +60,7 @@ public KestrelServerImpl( } // For testing - internal KestrelServerImpl(IConnectionListenerFactory transportFactory, ServiceContext serviceContext) - : this(new[] { transportFactory }, Array.Empty(), serviceContext) - { - } - // For testing internal KestrelServerImpl( IEnumerable transportFactories, IEnumerable multiplexedFactories, diff --git a/src/Servers/Kestrel/Core/src/KestrelServerOptions.cs b/src/Servers/Kestrel/Core/src/KestrelServerOptions.cs index 9a76bb8432cd..c216af615b8d 100644 --- a/src/Servers/Kestrel/Core/src/KestrelServerOptions.cs +++ b/src/Servers/Kestrel/Core/src/KestrelServerOptions.cs @@ -8,6 +8,7 @@ using System.Text; using System.Text.Json; using Microsoft.AspNetCore.Certificates.Generation; +using Microsoft.AspNetCore.Connections; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Server.Kestrel.Core.Internal; using Microsoft.AspNetCore.Server.Kestrel.Https; @@ -364,7 +365,7 @@ public KestrelConfigurationLoader Configure(IConfiguration config, bool reloadOn } /// - /// Bind to given IP address and port. + /// Bind to the given IP address and port. /// public void Listen(IPAddress address, int port) { @@ -372,7 +373,7 @@ public void Listen(IPAddress address, int port) } /// - /// Bind to given IP address and port. + /// Bind to the given IP address and port. /// The callback configures endpoint-specific settings. /// public void Listen(IPAddress address, int port, Action configure) @@ -400,7 +401,7 @@ public void Listen(EndPoint endPoint) } /// - /// Bind to given IP address and port. + /// Bind to the given IP address and port. /// The callback configures endpoint-specific settings. /// public void Listen(IPEndPoint endPoint, Action configure) @@ -462,7 +463,7 @@ public void ListenAnyIP(int port, Action configure) } /// - /// Bind to given Unix domain socket path. + /// Bind to the given Unix domain socket path. /// public void ListenUnixSocket(string socketPath) { @@ -470,7 +471,7 @@ public void ListenUnixSocket(string socketPath) } /// - /// Bind to given Unix domain socket path. + /// Bind to the given Unix domain socket path. /// Specify callback to configure endpoint-specific settings. /// public void ListenUnixSocket(string socketPath, Action configure) @@ -510,4 +511,27 @@ public void ListenHandle(ulong handle, Action configure) configure(listenOptions); CodeBackedListenOptions.Add(listenOptions); } + + /// + /// Bind to the given named pipe. + /// + public void ListenNamedPipe(string pipeName) + { + ListenNamedPipe(pipeName, _ => { }); + } + + /// + /// Bind to the given named pipe. + /// Specify callback to configure endpoint-specific settings. + /// + public void ListenNamedPipe(string pipeName, Action configure) + { + ArgumentNullException.ThrowIfNull(pipeName); + ArgumentNullException.ThrowIfNull(configure); + + var listenOptions = new ListenOptions(new NamedPipeEndPoint(pipeName)); + ApplyEndpointDefaults(listenOptions); + configure(listenOptions); + CodeBackedListenOptions.Add(listenOptions); + } } diff --git a/src/Servers/Kestrel/Core/src/ListenOptions.cs b/src/Servers/Kestrel/Core/src/ListenOptions.cs index c114c5a523c0..b8aaf3c75d72 100644 --- a/src/Servers/Kestrel/Core/src/ListenOptions.cs +++ b/src/Servers/Kestrel/Core/src/ListenOptions.cs @@ -11,7 +11,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core; /// -/// Describes either an , Unix domain socket path, or a file descriptor for an already open +/// Describes either an , Unix domain socket path, named pipe name, or a file descriptor for an already open /// socket that Kestrel should bind to or open. /// public class ListenOptions : IConnectionBuilder, IMultiplexedConnectionBuilder @@ -43,7 +43,7 @@ internal ListenOptions(ulong fileHandle, FileHandleType handleType) } /// - /// Gets the . + /// Gets the . /// public EndPoint EndPoint { get; internal set; } @@ -52,27 +52,44 @@ internal ListenOptions(ulong fileHandle, FileHandleType handleType) // IPEndPoint is mutable so port 0 can be updated to the bound port. /// - /// The to bind to. - /// Only set if the is . + /// Gets the bound . /// + /// + /// Only set if the is bound to a . + /// public IPEndPoint? IPEndPoint => EndPoint as IPEndPoint; /// - /// The absolute path to a Unix domain socket to bind to. - /// Only set if the is . + /// Gets the bound absolute path to a Unix domain socket. /// + /// + /// Only set if the is bound to a . + /// public string? SocketPath => (EndPoint as UnixDomainSocketEndPoint)?.ToString(); /// - /// A file descriptor for the socket to open. - /// Only set if the is . + /// Gets the bound pipe name to a name pipe server. /// + /// + /// Only set if the is bound to a . + /// + public string? PipeName => (EndPoint as NamedPipeEndPoint)?.PipeName.ToString(); + + /// + /// Gets the bound file descriptor to a socket. + /// + /// + /// Only set if the is bound to a . + /// public ulong FileHandle => (EndPoint as FileHandleEndPoint)?.FileHandle ?? 0; /// + /// Gets the for the listener options. /// Enables connection middleware to resolve and use services registered by the application during startup. - /// Only set if accessed from the callback of a Listen* method. /// + /// + /// Only set if accessed from the callback of a Listen* method. + /// public KestrelServerOptions KestrelServerOptions { get; internal set; } = default!; // Set via ConfigureKestrel callback /// @@ -135,6 +152,8 @@ internal virtual string GetDisplayName() { case UnixDomainSocketEndPoint _: return $"{Scheme}://unix:{EndPoint}"; + case NamedPipeEndPoint namedPipeEndPoint: + return $"{Scheme}://pipe:/{namedPipeEndPoint.PipeName}"; case FileHandleEndPoint _: return $"{Scheme}://"; default: diff --git a/src/Servers/Kestrel/Core/src/Properties/AssemblyInfo.cs b/src/Servers/Kestrel/Core/src/Properties/AssemblyInfo.cs index 2452d6492a99..1373f51edf54 100644 --- a/src/Servers/Kestrel/Core/src/Properties/AssemblyInfo.cs +++ b/src/Servers/Kestrel/Core/src/Properties/AssemblyInfo.cs @@ -12,4 +12,5 @@ [assembly: InternalsVisibleTo("Microsoft.AspNetCore.Server.Kestrel.Core.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] [assembly: InternalsVisibleTo("Microsoft.AspNetCore.Server.Kestrel.Microbenchmarks, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] [assembly: InternalsVisibleTo("Microsoft.AspNetCore.Server.Kestrel.Transport.Quic.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Server.Kestrel.Transport.NamedPipes.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] [assembly: InternalsVisibleTo("DynamicProxyGenAssembly2, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c547cac37abd99c8db225ef2f6c8a3602f3b3606cc9891605d02baa56104f4cfc0734aa39b93bf7852f7d9266654753cc297e7d2edfe0bac1cdcf9f717241550e0a7b191195b7667bb4f64bcb8e2121380fd1d9d46ad2d92d2d15605093924cceaf74c4861eff62abf69b9291ed0a340e113be11e6a7d3113e92484cf7045cc7")] diff --git a/src/Servers/Kestrel/Core/src/PublicAPI.Unshipped.txt b/src/Servers/Kestrel/Core/src/PublicAPI.Unshipped.txt index 34eec7f5f033..beef2c18089f 100644 --- a/src/Servers/Kestrel/Core/src/PublicAPI.Unshipped.txt +++ b/src/Servers/Kestrel/Core/src/PublicAPI.Unshipped.txt @@ -1,3 +1,6 @@ #nullable enable +Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerOptions.ListenNamedPipe(string! pipeName) -> void +Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerOptions.ListenNamedPipe(string! pipeName, System.Action! configure) -> void +Microsoft.AspNetCore.Server.Kestrel.Core.ListenOptions.PipeName.get -> string? Microsoft.AspNetCore.Server.Kestrel.Https.HttpsConnectionAdapterOptions.ServerCertificateChain.get -> System.Security.Cryptography.X509Certificates.X509Certificate2Collection? Microsoft.AspNetCore.Server.Kestrel.Https.HttpsConnectionAdapterOptions.ServerCertificateChain.set -> void diff --git a/src/Servers/Kestrel/Core/test/AddressBinderTests.cs b/src/Servers/Kestrel/Core/test/AddressBinderTests.cs index c63d207f8af9..b36660bb95e7 100644 --- a/src/Servers/Kestrel/Core/test/AddressBinderTests.cs +++ b/src/Servers/Kestrel/Core/test/AddressBinderTests.cs @@ -78,6 +78,50 @@ public void ParseAddressLocalhost() Assert.False(https); } + [Fact] + public void ParseAddress_HasPipeNoSlash() + { + // Pipe prefix is missing slash here and so the address is parsed as an IP. + // The slash is required to differentiate between a pipe and a hostname. + var listenOptions = AddressBinder.ParseAddress("http://pipe:8080", out var https); + Assert.IsType(listenOptions.EndPoint); + Assert.Equal(8080, listenOptions.IPEndPoint.Port); + Assert.False(https); + } + + [Fact] + public void ParseAddressNamedPipe() + { + var address = "http://pipe:/HelloWorld"; + var listenOptions = AddressBinder.ParseAddress(address, out var https); + Assert.IsType(listenOptions.EndPoint); + Assert.Equal("HelloWorld", listenOptions.PipeName); + Assert.False(https); + Assert.Equal(address, listenOptions.GetDisplayName()); + } + + [Fact] + public void ParseAddressNamedPipe_BackSlashes() + { + var address = @"http://pipe:/LOCAL\HelloWorld"; + var listenOptions = AddressBinder.ParseAddress(address, out var https); + Assert.IsType(listenOptions.EndPoint); + Assert.Equal(@"LOCAL\HelloWorld", listenOptions.PipeName); + Assert.False(https); + Assert.Equal(address, listenOptions.GetDisplayName()); + } + + [Fact] + public void ParseAddressNamedPipe_ForwardSlashes() + { + var address = "http://pipe://tmp/kestrel-test.sock"; + var listenOptions = AddressBinder.ParseAddress(address, out var https); + Assert.IsType(listenOptions.EndPoint); + Assert.Equal("/tmp/kestrel-test.sock", listenOptions.PipeName); + Assert.False(https); + Assert.Equal(address, listenOptions.GetDisplayName()); + } + [ConditionalFact] [OSSkipCondition(OperatingSystems.Windows, SkipReason = "tmp/kestrel-test.sock is not valid for windows. Unix socket path must be absolute.")] public void ParseAddressUnixPipe() diff --git a/src/Servers/Kestrel/Core/test/KestrelServerTests.cs b/src/Servers/Kestrel/Core/test/KestrelServerTests.cs index 927c6bf84694..7b6d155b5103 100644 --- a/src/Servers/Kestrel/Core/test/KestrelServerTests.cs +++ b/src/Servers/Kestrel/Core/test/KestrelServerTests.cs @@ -714,7 +714,7 @@ public void StartingServerInitializesHeartbeat() DebuggerWrapper.Singleton, testContext.Log); - using (var server = new KestrelServerImpl(new MockTransportFactory(), testContext)) + using (var server = new KestrelServerImpl(new[] { new MockTransportFactory() }, Array.Empty(), testContext)) { Assert.Null(testContext.DateHeaderValueManager.GetDateHeaderValues()); diff --git a/src/Servers/Kestrel/Kestrel.slnf b/src/Servers/Kestrel/Kestrel.slnf index 609785637faa..01e43f664ada 100644 --- a/src/Servers/Kestrel/Kestrel.slnf +++ b/src/Servers/Kestrel/Kestrel.slnf @@ -39,6 +39,8 @@ "src\\Servers\\Kestrel\\Core\\test\\Microsoft.AspNetCore.Server.Kestrel.Core.Tests.csproj", "src\\Servers\\Kestrel\\Kestrel\\src\\Microsoft.AspNetCore.Server.Kestrel.csproj", "src\\Servers\\Kestrel\\Kestrel\\test\\Microsoft.AspNetCore.Server.Kestrel.Tests.csproj", + "src\\Servers\\Kestrel\\Transport.NamedPipes\\src\\Microsoft.AspNetCore.Server.Kestrel.Transport.NamedPipes.csproj", + "src\\Servers\\Kestrel\\Transport.NamedPipes\\test\\Microsoft.AspNetCore.Server.Kestrel.Transport.NamedPipes.Tests.csproj", "src\\Servers\\Kestrel\\Transport.Quic\\src\\Microsoft.AspNetCore.Server.Kestrel.Transport.Quic.csproj", "src\\Servers\\Kestrel\\Transport.Quic\\test\\Microsoft.AspNetCore.Server.Kestrel.Transport.Quic.Tests.csproj", "src\\Servers\\Kestrel\\Transport.Sockets\\src\\Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.csproj", diff --git a/src/Servers/Kestrel/Kestrel/src/Microsoft.AspNetCore.Server.Kestrel.csproj b/src/Servers/Kestrel/Kestrel/src/Microsoft.AspNetCore.Server.Kestrel.csproj index 3df89b2b81ed..cc61f6a7f5db 100644 --- a/src/Servers/Kestrel/Kestrel/src/Microsoft.AspNetCore.Server.Kestrel.csproj +++ b/src/Servers/Kestrel/Kestrel/src/Microsoft.AspNetCore.Server.Kestrel.csproj @@ -13,6 +13,7 @@ + diff --git a/src/Servers/Kestrel/Kestrel/src/WebHostBuilderKestrelExtensions.cs b/src/Servers/Kestrel/Kestrel/src/WebHostBuilderKestrelExtensions.cs index 65babec4060e..28c356e30cef 100644 --- a/src/Servers/Kestrel/Kestrel/src/WebHostBuilderKestrelExtensions.cs +++ b/src/Servers/Kestrel/Kestrel/src/WebHostBuilderKestrelExtensions.cs @@ -29,6 +29,15 @@ public static class WebHostBuilderKestrelExtensions /// public static IWebHostBuilder UseKestrel(this IWebHostBuilder hostBuilder) { + hostBuilder.ConfigureServices(services => + { + // Don't override an already-configured transport + services.TryAddSingleton(); + + services.AddTransient, KestrelServerOptionsSetup>(); + services.AddSingleton(); + }); + hostBuilder.UseQuic(options => { // Configure server defaults to match client defaults. @@ -37,14 +46,9 @@ public static IWebHostBuilder UseKestrel(this IWebHostBuilder hostBuilder) options.DefaultCloseErrorCode = (long)Http3ErrorCode.NoError; }); - return hostBuilder.ConfigureServices(services => - { - // Don't override an already-configured transport - services.TryAddSingleton(); + hostBuilder.UseNamedPipes(); - services.AddTransient, KestrelServerOptionsSetup>(); - services.AddSingleton(); - }); + return hostBuilder; } /// diff --git a/src/Servers/Kestrel/Kestrel/test/WebHostBuilderKestrelExtensionsTests.cs b/src/Servers/Kestrel/Kestrel/test/WebHostBuilderKestrelExtensionsTests.cs index 124409f7d32f..6e69a288678d 100644 --- a/src/Servers/Kestrel/Kestrel/test/WebHostBuilderKestrelExtensionsTests.cs +++ b/src/Servers/Kestrel/Kestrel/test/WebHostBuilderKestrelExtensionsTests.cs @@ -1,10 +1,12 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Collections; using Microsoft.AspNetCore.Connections; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.Server.Kestrel.Core; +using Microsoft.AspNetCore.Server.Kestrel.Transport.NamedPipes.Internal; using Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets; using Microsoft.Extensions.DependencyInjection; using Xunit; @@ -51,13 +53,16 @@ public void ApplicationServicesNotNullDuringUseKestrelWithOptions() } [Fact] - public void SocketTransportIsTheDefault() + public void DefaultTransportFactoriesConfigured() { var hostBuilder = new WebHostBuilder() .UseKestrel() .Configure(app => { }); - Assert.IsType(hostBuilder.Build().Services.GetService()); + var transportFactories = hostBuilder.Build().Services.GetServices(); + Assert.Collection(transportFactories, + t => Assert.IsType(t), + t => Assert.IsType(t)); } [Fact] @@ -68,14 +73,21 @@ public void SocketsTransportCanBeManuallySelectedIndependentOfOrder() .UseSockets() .Configure(app => { }); - Assert.IsType(hostBuilder.Build().Services.GetService()); + var factories = hostBuilder.Build().Services.GetServices(); + AssertContainsType(factories); var hostBuilderReversed = new WebHostBuilder() .UseSockets() .UseKestrel() .Configure(app => { }); - Assert.IsType(hostBuilderReversed.Build().Services.GetService()); + var factoriesReversed = hostBuilderReversed.Build().Services.GetServices(); + AssertContainsType(factoriesReversed); + + static void AssertContainsType(IEnumerable enumerable) + { + Assert.Contains(enumerable, f => f is TExpected); + } } [Fact] diff --git a/src/Servers/Kestrel/Transport.NamedPipes/src/Internal/NamedPipeConnection.cs b/src/Servers/Kestrel/Transport.NamedPipes/src/Internal/NamedPipeConnection.cs new file mode 100644 index 000000000000..db089ea66137 --- /dev/null +++ b/src/Servers/Kestrel/Transport.NamedPipes/src/Internal/NamedPipeConnection.cs @@ -0,0 +1,293 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers; +using System.IO.Pipelines; +using System.IO.Pipes; +using Microsoft.AspNetCore.Connections; +using Microsoft.AspNetCore.Connections.Features; +using Microsoft.Extensions.Logging; +using PipeOptions = System.IO.Pipelines.PipeOptions; + +namespace Microsoft.AspNetCore.Server.Kestrel.Transport.NamedPipes.Internal; + +internal sealed class NamedPipeConnection : TransportConnection, IConnectionNamedPipeFeature +{ + private const int MinAllocBufferSize = 4096; + + private readonly NamedPipeServerStream _stream; + private readonly ILogger _log; + private readonly IDuplexPipe _originalTransport; + + private readonly CancellationTokenSource _connectionClosedTokenSource = new CancellationTokenSource(); + private bool _connectionClosed; + private bool _connectionDisposed; + private Exception? _shutdownReason; + private readonly object _shutdownLock = new object(); + + private Task _receivingTask = Task.CompletedTask; + private Task _sendingTask = Task.CompletedTask; + + public NamedPipeConnection( + NamedPipeServerStream stream, + NamedPipeEndPoint endPoint, + ILogger logger, + MemoryPool memoryPool, + PipeOptions inputOptions, + PipeOptions outputOptions) + { + _stream = stream; + _log = logger; + MemoryPool = memoryPool; + LocalEndPoint = endPoint; + ConnectionClosed = _connectionClosedTokenSource.Token; + + var pair = DuplexPipe.CreateConnectionPair(inputOptions, outputOptions); + + Transport = _originalTransport = pair.Transport; + Application = pair.Application; + + Features.Set(this); + } + + public PipeWriter Input => Application.Output; + public PipeReader Output => Application.Input; + public override MemoryPool MemoryPool { get; } + public NamedPipeServerStream NamedPipe => _stream; + + public void Start() + { + try + { + // Spawn send and receive logic + _receivingTask = DoReceiveAsync(); + _sendingTask = DoSendAsync(); + } + catch (Exception ex) + { + _log.LogError(0, ex, $"Unexpected exception in {nameof(NamedPipeConnection)}.{nameof(Start)}."); + } + } + + private async Task DoReceiveAsync() + { + Exception? error = null; + + try + { + var input = Input; + while (true) + { + // Ensure we have some reasonable amount of buffer space + var buffer = input.GetMemory(MinAllocBufferSize); + var bytesReceived = await _stream.ReadAsync(buffer); + + if (bytesReceived == 0) + { + // Read completed. + NamedPipeLog.ConnectionReadEnd(_log, this); + break; + } + + input.Advance(bytesReceived); + + var flushTask = Input.FlushAsync(); + + var paused = !flushTask.IsCompleted; + + if (paused) + { + NamedPipeLog.ConnectionPause(_log, this); + } + + var result = await flushTask; + + if (paused) + { + NamedPipeLog.ConnectionResume(_log, this); + } + + if (result.IsCompleted || result.IsCanceled) + { + // Pipe consumer is shut down, do we stop writing + break; + } + } + } + catch (ObjectDisposedException ex) + { + // This exception should always be ignored because _shutdownReason should be set. + error = ex; + + if (!_connectionDisposed) + { + // This is unexpected if the socket hasn't been disposed yet. + NamedPipeLog.ConnectionError(_log, this, error); + } + } + catch (Exception ex) + { + // This is unexpected. + error = ex; + NamedPipeLog.ConnectionError(_log, this, error); + } + finally + { + // If Shutdown() has already been called, assume that was the reason ProcessReceives() exited. + Input.Complete(_shutdownReason ?? error); + + FireConnectionClosed(); + } + } + + private async Task DoSendAsync() + { + Exception? shutdownReason = null; + Exception? unexpectedError = null; + + try + { + while (true) + { + var result = await Output.ReadAsync(); + + if (result.IsCanceled) + { + break; + } + var buffer = result.Buffer; + + if (buffer.IsSingleSegment) + { + // Fast path when the buffer is a single segment. + await _stream.WriteAsync(buffer.First); + } + else + { + foreach (var segment in buffer) + { + await _stream.WriteAsync(segment); + } + } + + Output.AdvanceTo(buffer.End); + + if (result.IsCompleted) + { + break; + } + } + } + catch (ObjectDisposedException ex) + { + // This should always be ignored since Shutdown() must have already been called by Abort(). + shutdownReason = ex; + } + catch (Exception ex) + { + shutdownReason = ex; + unexpectedError = ex; + NamedPipeLog.ConnectionError(_log, this, unexpectedError); + } + finally + { + Shutdown(shutdownReason); + + // Complete the output after disposing the socket + Output.Complete(unexpectedError); + + // Cancel any pending flushes so that the input loop is un-paused + Input.CancelPendingFlush(); + } + } + + private void Shutdown(Exception? shutdownReason) + { + lock (_shutdownLock) + { + if (_connectionDisposed) + { + return; + } + + // Make sure to close the connection only after the _aborted flag is set. + // Without this, the RequestsCanBeAbortedMidRead test will sometimes fail when + // a BadHttpRequestException is thrown instead of a TaskCanceledException. + _connectionDisposed = true; + + // shutdownReason should only be null if the output was completed gracefully, so no one should ever + // ever observe the nondescript ConnectionAbortedException except for connection middleware attempting + // to half close the connection which is currently unsupported. + _shutdownReason = shutdownReason ?? new ConnectionAbortedException("The Socket transport's send loop completed gracefully."); + NamedPipeLog.ConnectionDisconnect(_log, this, _shutdownReason.Message); + + try + { + // Try to gracefully close the socket even for aborts to match libuv behavior. + _stream.Disconnect(); + } + catch + { + // Ignore any errors from NamedPipeStream.Disconnect() since we're tearing down the connection anyway. + } + + _stream.Dispose(); + } + } + + private void FireConnectionClosed() + { + // Guard against scheduling this multiple times + lock (_shutdownLock) + { + if (_connectionClosed) + { + return; + } + + _connectionClosed = true; + } + + CancelConnectionClosedToken(); + } + + private void CancelConnectionClosedToken() + { + try + { + _connectionClosedTokenSource.Cancel(); + } + catch (Exception ex) + { + _log.LogError(0, ex, $"Unexpected exception in {nameof(NamedPipeConnection)}.{nameof(CancelConnectionClosedToken)}."); + } + } + + public override void Abort(ConnectionAbortedException abortReason) + { + // Try to gracefully close the socket to match libuv behavior. + Shutdown(abortReason); + + // Cancel ProcessSends loop after calling shutdown to ensure the correct _shutdownReason gets set. + Output.CancelPendingRead(); + } + + public override async ValueTask DisposeAsync() + { + _originalTransport.Input.Complete(); + _originalTransport.Output.Complete(); + + try + { + // Now wait for both to complete + await _receivingTask; + await _sendingTask; + } + catch (Exception ex) + { + _log.LogError(0, ex, $"Unexpected exception in {nameof(NamedPipeConnection)}.{nameof(Start)}."); + } + + await _stream.DisposeAsync(); + } +} diff --git a/src/Servers/Kestrel/Transport.NamedPipes/src/Internal/NamedPipeConnectionListener.cs b/src/Servers/Kestrel/Transport.NamedPipes/src/Internal/NamedPipeConnectionListener.cs new file mode 100644 index 000000000000..693f8d4b23d6 --- /dev/null +++ b/src/Servers/Kestrel/Transport.NamedPipes/src/Internal/NamedPipeConnectionListener.cs @@ -0,0 +1,181 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers; +using System.Diagnostics; +using System.IO.Pipelines; +using System.IO.Pipes; +using System.Net; +using System.Threading.Channels; +using Microsoft.AspNetCore.Connections; +using Microsoft.Extensions.Logging; +using NamedPipeOptions = System.IO.Pipes.PipeOptions; +using PipeOptions = System.IO.Pipelines.PipeOptions; + +namespace Microsoft.AspNetCore.Server.Kestrel.Transport.NamedPipes.Internal; + +internal sealed class NamedPipeConnectionListener : IConnectionListener +{ + private readonly ILogger _log; + private readonly NamedPipeEndPoint _endpoint; + private readonly NamedPipeTransportOptions _options; + private readonly CancellationTokenSource _listeningTokenSource = new CancellationTokenSource(); + private readonly CancellationToken _listeningToken; + private readonly Channel _acceptedQueue; + private readonly MemoryPool _memoryPool; + private readonly PipeOptions _inputOptions; + private readonly PipeOptions _outputOptions; + private readonly Mutex _mutex; + private Task? _listeningTask; + private int _disposed; + + public NamedPipeConnectionListener( + NamedPipeEndPoint endpoint, + NamedPipeTransportOptions options, + ILoggerFactory loggerFactory, + Mutex mutex) + { + _log = loggerFactory.CreateLogger("Microsoft.AspNetCore.Server.Kestrel.Transport.NamedPipes"); + _endpoint = endpoint; + _options = options; + _mutex = mutex; + _memoryPool = options.MemoryPoolFactory(); + _listeningToken = _listeningTokenSource.Token; + + // The OS maintains a backlog of clients that are waiting to connect, so the app queue only stores a single connection. + // We want to have a queue plus a background task that populates the queue, rather than creating NamedPipeServerStream + // when AcceptAsync is called, so that the server is always the owner of the pipe name. + _acceptedQueue = Channel.CreateBounded(new BoundedChannelOptions(capacity: 1) { SingleWriter = true }); + + var maxReadBufferSize = _options.MaxReadBufferSize ?? 0; + var maxWriteBufferSize = _options.MaxWriteBufferSize ?? 0; + + _inputOptions = new PipeOptions(_memoryPool, PipeScheduler.ThreadPool, PipeScheduler.Inline, maxReadBufferSize, maxReadBufferSize / 2, useSynchronizationContext: false); + _outputOptions = new PipeOptions(_memoryPool, PipeScheduler.Inline, PipeScheduler.ThreadPool, maxWriteBufferSize, maxWriteBufferSize / 2, useSynchronizationContext: false); + } + + public void Start() + { + Debug.Assert(_listeningTask == null, "Already started"); + + // Start first stream inline to catch creation errors. + var initialStream = CreateServerStream(); + + _listeningTask = StartAsync(initialStream); + } + + public EndPoint EndPoint => _endpoint; + + private async Task StartAsync(NamedPipeServerStream nextStream) + { + try + { + while (true) + { + try + { + var stream = nextStream; + + await stream.WaitForConnectionAsync(_listeningToken); + + var connection = new NamedPipeConnection(stream, _endpoint, _log, _memoryPool, _inputOptions, _outputOptions); + connection.Start(); + + // Create the next stream before writing connected stream to the channel. + // This ensures there is always a created stream and another process can't + // create a stream with the same name with different a access policy. + nextStream = CreateServerStream(); + + while (!_acceptedQueue.Writer.TryWrite(connection)) + { + if (!await _acceptedQueue.Writer.WaitToWriteAsync(_listeningToken)) + { + throw new InvalidOperationException("Accept queue writer was unexpectedly closed."); + } + } + } + catch (OperationCanceledException ex) when (_listeningToken.IsCancellationRequested) + { + // Cancelled the current token + NamedPipeLog.ConnectionListenerAborted(_log, ex); + break; + } + } + + nextStream.Dispose(); + _acceptedQueue.Writer.TryComplete(); + } + catch (Exception ex) + { + _acceptedQueue.Writer.TryComplete(ex); + } + } + + private NamedPipeServerStream CreateServerStream() + { + NamedPipeServerStream stream; + var pipeOptions = NamedPipeOptions.Asynchronous | NamedPipeOptions.WriteThrough; + if (_options.CurrentUserOnly) + { + pipeOptions |= NamedPipeOptions.CurrentUserOnly; + } + + if (_options.PipeSecurity != null) + { + stream = NamedPipeServerStreamAcl.Create( + _endpoint.PipeName, + PipeDirection.InOut, + NamedPipeServerStream.MaxAllowedServerInstances, + PipeTransmissionMode.Byte, + pipeOptions, + inBufferSize: 0, // Buffer in System.IO.Pipelines + outBufferSize: 0, // Buffer in System.IO.Pipelines + _options.PipeSecurity); + } + else + { + stream = new NamedPipeServerStream( + _endpoint.PipeName, + PipeDirection.InOut, + NamedPipeServerStream.MaxAllowedServerInstances, + PipeTransmissionMode.Byte, + pipeOptions, + inBufferSize: 0, + outBufferSize: 0); + } + return stream; + } + + public async ValueTask AcceptAsync(CancellationToken cancellationToken = default) + { + while (await _acceptedQueue.Reader.WaitToReadAsync(cancellationToken)) + { + if (_acceptedQueue.Reader.TryRead(out var connection)) + { + NamedPipeLog.AcceptedConnection(_log, connection); + return connection; + } + } + + return null; + } + + public ValueTask UnbindAsync(CancellationToken cancellationToken = default) => DisposeAsync(); + + public async ValueTask DisposeAsync() + { + // A stream may be waiting on WaitForConnectionAsync when dispose happens. + // Cancel the token before dispose to ensure StartAsync exits. + if (Interlocked.Exchange(ref _disposed, 1) == 0) + { + _listeningTokenSource.Cancel(); + } + + _listeningTokenSource.Dispose(); + _mutex.Dispose(); + if (_listeningTask != null) + { + await _listeningTask; + } + } +} diff --git a/src/Servers/Kestrel/Transport.NamedPipes/src/Internal/NamedPipeLog.cs b/src/Servers/Kestrel/Transport.NamedPipes/src/Internal/NamedPipeLog.cs new file mode 100644 index 000000000000..0adc04481e1f --- /dev/null +++ b/src/Servers/Kestrel/Transport.NamedPipes/src/Internal/NamedPipeLog.cs @@ -0,0 +1,80 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Connections; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Server.Kestrel.Transport.NamedPipes.Internal; + +internal static partial class NamedPipeLog +{ + [LoggerMessage(1, LogLevel.Debug, @"Connection id ""{ConnectionId}"" accepted.", EventName = "AcceptedConnection", SkipEnabledCheck = true)] + private static partial void AcceptedConnectionCore(ILogger logger, string connectionId); + + public static void AcceptedConnection(ILogger logger, BaseConnectionContext connection) + { + if (logger.IsEnabled(LogLevel.Debug)) + { + AcceptedConnectionCore(logger, connection.ConnectionId); + } + } + + [LoggerMessage(2, LogLevel.Debug, @"Connection id ""{ConnectionId}"" unexpected error.", EventName = "ConnectionError", SkipEnabledCheck = true)] + private static partial void ConnectionErrorCore(ILogger logger, string connectionId, Exception ex); + + public static void ConnectionError(ILogger logger, BaseConnectionContext connection, Exception ex) + { + if (logger.IsEnabled(LogLevel.Debug)) + { + ConnectionErrorCore(logger, connection.ConnectionId, ex); + } + } + + [LoggerMessage(3, LogLevel.Debug, "Named pipe listener aborted.", EventName = "ConnectionListenerAborted")] + public static partial void ConnectionListenerAborted(ILogger logger, Exception exception); + + [LoggerMessage(4, LogLevel.Debug, @"Connection id ""{ConnectionId}"" paused.", EventName = "ConnectionPause", SkipEnabledCheck = true)] + private static partial void ConnectionPauseCore(ILogger logger, string connectionId); + + public static void ConnectionPause(ILogger logger, NamedPipeConnection connection) + { + if (logger.IsEnabled(LogLevel.Debug)) + { + ConnectionPauseCore(logger, connection.ConnectionId); + } + } + + [LoggerMessage(5, LogLevel.Debug, @"Connection id ""{ConnectionId}"" resumed.", EventName = "ConnectionResume", SkipEnabledCheck = true)] + private static partial void ConnectionResumeCore(ILogger logger, string connectionId); + + public static void ConnectionResume(ILogger logger, NamedPipeConnection connection) + { + if (logger.IsEnabled(LogLevel.Debug)) + { + ConnectionResumeCore(logger, connection.ConnectionId); + } + } + + [LoggerMessage(6, LogLevel.Debug, @"Connection id ""{ConnectionId}"" received end of stream.", EventName = "ConnectionReadEnd", SkipEnabledCheck = true)] + private static partial void ConnectionReadEndCore(ILogger logger, string connectionId); + + public static void ConnectionReadEnd(ILogger logger, NamedPipeConnection connection) + { + if (logger.IsEnabled(LogLevel.Debug)) + { + ConnectionReadEndCore(logger, connection.ConnectionId); + } + } + + [LoggerMessage(7, LogLevel.Debug, @"Connection id ""{ConnectionId}"" disconnecting stream because: ""{Reason}""", EventName = "ConnectionDisconnect", SkipEnabledCheck = true)] + private static partial void ConnectionDisconnectCore(ILogger logger, string connectionId, string reason); + + public static void ConnectionDisconnect(ILogger logger, NamedPipeConnection connection, string reason) + { + if (logger.IsEnabled(LogLevel.Debug)) + { + ConnectionDisconnectCore(logger, connection.ConnectionId, reason); + } + } + +} diff --git a/src/Servers/Kestrel/Transport.NamedPipes/src/Internal/NamedPipeTransportFactory.cs b/src/Servers/Kestrel/Transport.NamedPipes/src/Internal/NamedPipeTransportFactory.cs new file mode 100644 index 000000000000..7b39ab2217a9 --- /dev/null +++ b/src/Servers/Kestrel/Transport.NamedPipes/src/Internal/NamedPipeTransportFactory.cs @@ -0,0 +1,64 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net; +using Microsoft.AspNetCore.Connections; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.Server.Kestrel.Transport.NamedPipes.Internal; + +internal sealed class NamedPipeTransportFactory : IConnectionListenerFactory, IConnectionListenerFactorySelector +{ + private const string LocalComputerServerName = "."; + + private readonly ILoggerFactory _loggerFactory; + private readonly NamedPipeTransportOptions _options; + + public NamedPipeTransportFactory( + ILoggerFactory loggerFactory, + IOptions options) + { + ArgumentNullException.ThrowIfNull(loggerFactory); + + _loggerFactory = loggerFactory; + _options = options.Value; + } + + public ValueTask BindAsync(EndPoint endpoint, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(endpoint); + + if (endpoint is not NamedPipeEndPoint namedPipeEndPoint) + { + throw new NotSupportedException($"{endpoint.GetType()} is not supported."); + } + if (namedPipeEndPoint.ServerName != LocalComputerServerName) + { + throw new NotSupportedException($@"Server name '{namedPipeEndPoint.ServerName}' is invalid. The server name must be ""{LocalComputerServerName}""."); + } + + // Creating a named pipe server with an name isn't exclusive. Create a mutex with the pipe name to prevent multiple endpoints + // accidently sharing the same pipe name. Will detect across Kestrel processes. + // Note that this doesn't prevent other applications from using the pipe name. + var mutexName = "Kestrel-NamedPipe-" + namedPipeEndPoint.PipeName; + var mutex = new Mutex(false, mutexName, out var createdNew); + if (!createdNew) + { + mutex.Dispose(); + throw new AddressInUseException($"Named pipe '{namedPipeEndPoint.PipeName}' is already in use by Kestrel."); + } + + var listener = new NamedPipeConnectionListener(namedPipeEndPoint, _options, _loggerFactory, mutex); + listener.Start(); + + return new ValueTask(listener); + } + + public bool CanBind(EndPoint endpoint) + { + ArgumentNullException.ThrowIfNull(endpoint); + + return endpoint is NamedPipeEndPoint; + } +} diff --git a/src/Servers/Kestrel/Transport.NamedPipes/src/Microsoft.AspNetCore.Server.Kestrel.Transport.NamedPipes.csproj b/src/Servers/Kestrel/Transport.NamedPipes/src/Microsoft.AspNetCore.Server.Kestrel.Transport.NamedPipes.csproj new file mode 100644 index 000000000000..bbe5da6ad877 --- /dev/null +++ b/src/Servers/Kestrel/Transport.NamedPipes/src/Microsoft.AspNetCore.Server.Kestrel.Transport.NamedPipes.csproj @@ -0,0 +1,37 @@ + + + + Managed socket transport for the ASP.NET Core Kestrel cross-platform web server. + $(DefaultNetCoreTargetFramework) + true + true + aspnetcore;kestrel + true + false + true + + + $(NoWarn);CA1416 + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Servers/Kestrel/Transport.NamedPipes/src/NamedPipeTransportOptions.cs b/src/Servers/Kestrel/Transport.NamedPipes/src/NamedPipeTransportOptions.cs new file mode 100644 index 000000000000..824c7ba74357 --- /dev/null +++ b/src/Servers/Kestrel/Transport.NamedPipes/src/NamedPipeTransportOptions.cs @@ -0,0 +1,56 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers; +using System.IO.Pipes; + +namespace Microsoft.AspNetCore.Server.Kestrel.Transport.NamedPipes; + +/// +/// Options for named pipe based transports. +/// +public sealed class NamedPipeTransportOptions +{ + /// + /// Gets or sets the maximum unconsumed incoming bytes the transport will buffer. + /// + /// A value of or 0 disables backpressure entirely allowing unlimited buffering. + /// Unlimited server buffering is a security risk given untrusted clients. + /// + /// + /// + /// Defaults to 1 MiB. + /// + public long? MaxReadBufferSize { get; set; } = 1024 * 1024; + + /// + /// Gets or sets the maximum outgoing bytes the transport will buffer before applying write backpressure. + /// + /// A value of or 0 disables backpressure entirely allowing unlimited buffering. + /// Unlimited server buffering is a security risk given untrusted clients. + /// + /// + /// + /// Defaults to 64 KiB. + /// + public long? MaxWriteBufferSize { get; set; } = 64 * 1024; + + /// + /// Gets or sets a value that indicates that the pipe can only be connected to by a client created by + /// the same user account. + /// + /// On Windows, a value of true verifies both the user account and elevation level. + /// + /// + /// + /// Defaults to true. + /// + public bool CurrentUserOnly { get; set; } = true; + + /// + /// Gets or sets the security information that determines the access control and audit security for pipes. + /// + public PipeSecurity? PipeSecurity { get; set; } + + internal Func> MemoryPoolFactory { get; set; } = PinnedBlockMemoryPoolFactory.Create; +} diff --git a/src/Servers/Kestrel/Transport.NamedPipes/src/PublicAPI.Shipped.txt b/src/Servers/Kestrel/Transport.NamedPipes/src/PublicAPI.Shipped.txt new file mode 100644 index 000000000000..7dc5c58110bf --- /dev/null +++ b/src/Servers/Kestrel/Transport.NamedPipes/src/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/Servers/Kestrel/Transport.NamedPipes/src/PublicAPI.Unshipped.txt b/src/Servers/Kestrel/Transport.NamedPipes/src/PublicAPI.Unshipped.txt new file mode 100644 index 000000000000..74e0993c7a85 --- /dev/null +++ b/src/Servers/Kestrel/Transport.NamedPipes/src/PublicAPI.Unshipped.txt @@ -0,0 +1,14 @@ +#nullable enable +Microsoft.AspNetCore.Hosting.WebHostBuilderNamedPipeExtensions +Microsoft.AspNetCore.Server.Kestrel.Transport.NamedPipes.NamedPipeTransportOptions +Microsoft.AspNetCore.Server.Kestrel.Transport.NamedPipes.NamedPipeTransportOptions.CurrentUserOnly.get -> bool +Microsoft.AspNetCore.Server.Kestrel.Transport.NamedPipes.NamedPipeTransportOptions.CurrentUserOnly.set -> void +Microsoft.AspNetCore.Server.Kestrel.Transport.NamedPipes.NamedPipeTransportOptions.MaxReadBufferSize.get -> long? +Microsoft.AspNetCore.Server.Kestrel.Transport.NamedPipes.NamedPipeTransportOptions.MaxReadBufferSize.set -> void +Microsoft.AspNetCore.Server.Kestrel.Transport.NamedPipes.NamedPipeTransportOptions.MaxWriteBufferSize.get -> long? +Microsoft.AspNetCore.Server.Kestrel.Transport.NamedPipes.NamedPipeTransportOptions.MaxWriteBufferSize.set -> void +Microsoft.AspNetCore.Server.Kestrel.Transport.NamedPipes.NamedPipeTransportOptions.NamedPipeTransportOptions() -> void +Microsoft.AspNetCore.Server.Kestrel.Transport.NamedPipes.NamedPipeTransportOptions.PipeSecurity.get -> System.IO.Pipes.PipeSecurity? +Microsoft.AspNetCore.Server.Kestrel.Transport.NamedPipes.NamedPipeTransportOptions.PipeSecurity.set -> void +static Microsoft.AspNetCore.Hosting.WebHostBuilderNamedPipeExtensions.UseNamedPipes(this Microsoft.AspNetCore.Hosting.IWebHostBuilder! hostBuilder) -> Microsoft.AspNetCore.Hosting.IWebHostBuilder! +static Microsoft.AspNetCore.Hosting.WebHostBuilderNamedPipeExtensions.UseNamedPipes(this Microsoft.AspNetCore.Hosting.IWebHostBuilder! hostBuilder, System.Action! configureOptions) -> Microsoft.AspNetCore.Hosting.IWebHostBuilder! diff --git a/src/Servers/Kestrel/Transport.NamedPipes/src/WebHostBuilderNamedPipeExtensions.cs b/src/Servers/Kestrel/Transport.NamedPipes/src/WebHostBuilderNamedPipeExtensions.cs new file mode 100644 index 000000000000..40918a2ec20b --- /dev/null +++ b/src/Servers/Kestrel/Transport.NamedPipes/src/WebHostBuilderNamedPipeExtensions.cs @@ -0,0 +1,43 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Connections; +using Microsoft.AspNetCore.Server.Kestrel.Transport.NamedPipes; +using Microsoft.AspNetCore.Server.Kestrel.Transport.NamedPipes.Internal; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.AspNetCore.Hosting; + +/// +/// extension methods to configure the Named Pipes transport to be used by Kestrel. +/// +public static class WebHostBuilderNamedPipeExtensions +{ + /// + /// Specify Named Pipes as the transport to be used by Kestrel. + /// + /// The to configure. + /// The . + public static IWebHostBuilder UseNamedPipes(this IWebHostBuilder hostBuilder) + { + hostBuilder.ConfigureServices(services => + { + services.AddSingleton(); + }); + return hostBuilder; + } + + /// + /// Specify Named Pipes as the transport to be used by Kestrel. + /// + /// The to configure. + /// A callback to configure transport options. + /// The . + public static IWebHostBuilder UseNamedPipes(this IWebHostBuilder hostBuilder, Action configureOptions) + { + return hostBuilder.UseNamedPipes().ConfigureServices(services => + { + services.Configure(configureOptions); + }); + } +} diff --git a/src/Servers/Kestrel/Transport.NamedPipes/test/Microsoft.AspNetCore.Server.Kestrel.Transport.NamedPipes.Tests.csproj b/src/Servers/Kestrel/Transport.NamedPipes/test/Microsoft.AspNetCore.Server.Kestrel.Transport.NamedPipes.Tests.csproj new file mode 100644 index 000000000000..5590ceb15c3a --- /dev/null +++ b/src/Servers/Kestrel/Transport.NamedPipes/test/Microsoft.AspNetCore.Server.Kestrel.Transport.NamedPipes.Tests.csproj @@ -0,0 +1,31 @@ + + + + $(DefaultNetCoreTargetFramework) + true + true + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Servers/Kestrel/Transport.NamedPipes/test/NamedPipeConnectionListenerTests.cs b/src/Servers/Kestrel/Transport.NamedPipes/test/NamedPipeConnectionListenerTests.cs new file mode 100644 index 000000000000..753a6e75f1e0 --- /dev/null +++ b/src/Servers/Kestrel/Transport.NamedPipes/test/NamedPipeConnectionListenerTests.cs @@ -0,0 +1,166 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.IO.Pipes; +using Microsoft.AspNetCore.Connections; +using Microsoft.AspNetCore.Testing; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Server.Kestrel.Transport.NamedPipes.Tests; + +public class NamedPipeConnectionListenerTests : TestApplicationErrorLoggerLoggedTest +{ + [Fact] + public async Task AcceptAsync_AfterUnbind_ReturnNull() + { + // Arrange + await using var connectionListener = await NamedPipeTestHelpers.CreateConnectionListenerFactory(LoggerFactory); + + // Act + await connectionListener.UnbindAsync().DefaultTimeout(); + + // Assert + Assert.Null(await connectionListener.AcceptAsync().DefaultTimeout()); + } + + [Fact] + public async Task AcceptAsync_ClientCreatesConnection_ServerAccepts() + { + // Arrange + await using var connectionListener = await NamedPipeTestHelpers.CreateConnectionListenerFactory(LoggerFactory); + + // Stream 1 + var acceptTask1 = connectionListener.AcceptAsync(); + await using var clientStream1 = NamedPipeTestHelpers.CreateClientStream(connectionListener.EndPoint); + await clientStream1.ConnectAsync(); + + var serverConnection1 = await acceptTask1.DefaultTimeout(); + Assert.False(serverConnection1.ConnectionClosed.IsCancellationRequested, "Connection 1 should be open"); + await serverConnection1.DisposeAsync().AsTask().DefaultTimeout(); + Assert.True(serverConnection1.ConnectionClosed.IsCancellationRequested, "Connection 1 should be closed"); + + // Stream 2 + var acceptTask2 = connectionListener.AcceptAsync(); + await using var clientStream2 = NamedPipeTestHelpers.CreateClientStream(connectionListener.EndPoint); + await clientStream2.ConnectAsync(); + + var serverConnection2 = await acceptTask2.DefaultTimeout(); + Assert.False(serverConnection2.ConnectionClosed.IsCancellationRequested, "Connection 2 should be open"); + await serverConnection2.DisposeAsync().AsTask().DefaultTimeout(); + Assert.True(serverConnection2.ConnectionClosed.IsCancellationRequested, "Connection 2 should be closed"); + } + + [Fact] + public async Task AcceptAsync_UnbindAfterCall_CleanExitAndLog() + { + // Arrange + await using var connectionListener = await NamedPipeTestHelpers.CreateConnectionListenerFactory(LoggerFactory); + + // Act + var acceptTask = connectionListener.AcceptAsync(); + + await connectionListener.UnbindAsync().DefaultTimeout(); + + // Assert + Assert.Null(await acceptTask.AsTask().DefaultTimeout()); + + Assert.Contains(LogMessages, m => m.EventId.Name == "ConnectionListenerAborted"); + } + + [ConditionalFact] + [OSSkipCondition(OperatingSystems.Linux | OperatingSystems.MacOSX, SkipReason = "Non-OS implementations use UDS with an unlimited accept limit.")] + public async Task AcceptAsync_HitBacklogLimit_ClientConnectionsSuccessfullyAccepted() + { + // Arrange + var options = new NamedPipeTransportOptions(); + await using var connectionListener = await NamedPipeTestHelpers.CreateConnectionListenerFactory(LoggerFactory, options: options); + + // Act + var clients = new List(); + var connectBlocked = false; + for (var i = 0; i < 100; i++) + { + Logger.LogInformation($"Connecting client {i}."); + + var clientStream = NamedPipeTestHelpers.CreateClientStream(connectionListener.EndPoint); + var connectTask = clientStream.ConnectAsync(); + + clients.Add(new ClientStreamContext(clientStream, connectTask)); + + try + { + // Attempt to connect for half a second. Assume we've hit the limit if this value is exceeded. + await connectTask.WaitAsync(TimeSpan.FromSeconds(0.5)); + Logger.LogInformation($"Client {i} connect success."); + } + catch (TimeoutException) + { + Logger.LogInformation($"Client {i} connect timeout."); + connectBlocked = true; + break; + } + } + + Assert.True(connectBlocked, "Connect should be blocked before reaching the end of the connect loop."); + + for (var i = 0; i < clients.Count; i++) + { + var client = clients[i]; + Logger.LogInformation($"Accepting client {i} on the server."); + var serverConnectionTask = connectionListener.AcceptAsync(); + + await client.ConnectTask.DefaultTimeout(); + client.ServerConnection = await serverConnectionTask.DefaultTimeout(); + + Logger.LogInformation($"Asserting client {i} is connected to the server."); + Assert.True(client.ClientStream.IsConnected, "IsConnected should be true."); + Assert.True(client.ConnectTask.IsCompletedSuccessfully, "ConnectTask should be completed."); + } + } + + private record ClientStreamContext(NamedPipeClientStream ClientStream, Task ConnectTask) + { + public ConnectionContext ServerConnection { get; set; } + } + + [Fact] + public async Task AcceptAsync_DisposeAfterCall_CleanExitAndLog() + { + // Arrange + await using var connectionListener = await NamedPipeTestHelpers.CreateConnectionListenerFactory(LoggerFactory); + + // Act + var acceptTask = connectionListener.AcceptAsync(); + + await connectionListener.DisposeAsync().DefaultTimeout(); + + // Assert + Assert.Null(await acceptTask.AsTask().DefaultTimeout()); + + Assert.Contains(LogMessages, m => m.EventId.Name == "ConnectionListenerAborted"); + } + + [Fact] + public async Task BindAsync_ListenersSharePort_ThrowAddressInUse() + { + // Arrange + await using var connectionListener1 = await NamedPipeTestHelpers.CreateConnectionListenerFactory(LoggerFactory); + var pipeName = ((NamedPipeEndPoint)connectionListener1.EndPoint).PipeName; + + // Act & Assert + await Assert.ThrowsAsync(() => NamedPipeTestHelpers.CreateConnectionListenerFactory(LoggerFactory, pipeName: pipeName)); + } + + [Fact] + public async Task BindAsync_ListenersSharePort_DisposeFirstListener_Success() + { + // Arrange + var connectionListener1 = await NamedPipeTestHelpers.CreateConnectionListenerFactory(LoggerFactory); + var pipeName = ((NamedPipeEndPoint)connectionListener1.EndPoint).PipeName; + await connectionListener1.DisposeAsync(); + + // Act & Assert + await using var connectionListener2 = await NamedPipeTestHelpers.CreateConnectionListenerFactory(LoggerFactory, pipeName: pipeName); + Assert.Equal(connectionListener1.EndPoint, connectionListener2.EndPoint); + } +} diff --git a/src/Servers/Kestrel/Transport.NamedPipes/test/NamedPipeConnectionTests.cs b/src/Servers/Kestrel/Transport.NamedPipes/test/NamedPipeConnectionTests.cs new file mode 100644 index 000000000000..0461d9389a35 --- /dev/null +++ b/src/Servers/Kestrel/Transport.NamedPipes/test/NamedPipeConnectionTests.cs @@ -0,0 +1,106 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text; +using Microsoft.AspNetCore.Connections; +using Microsoft.AspNetCore.Internal; +using Microsoft.AspNetCore.Testing; + +namespace Microsoft.AspNetCore.Server.Kestrel.Transport.NamedPipes.Tests; + +public class NamedPipeConnectionTests : TestApplicationErrorLoggerLoggedTest +{ + private static readonly byte[] TestData = Encoding.UTF8.GetBytes("Hello world"); + + [Fact] + public async Task BidirectionalStream_ServerReadsDataAndCompletes_GracefullyClosed() + { + // Arrange + using var httpEventSource = new HttpEventSourceListener(LoggerFactory); + + await using var connectionListener = await NamedPipeTestHelpers.CreateConnectionListenerFactory(LoggerFactory); + + // Act + await NamedPipeTestHelpers.CreateAndCompleteBidirectionalStreamGracefully( + NamedPipeTestHelpers.CreateClientStream(connectionListener.EndPoint), + connectionListener, + Logger); + + Assert.Contains(LogMessages, m => m.Message.Contains("send loop completed gracefully")); + } + + [Fact] + public async Task InputReadAsync_ServerAborted_ThrowError() + { + // Arrange + await using var connectionListener = await NamedPipeTestHelpers.CreateConnectionListenerFactory(LoggerFactory); + + // Act + var clientStream = NamedPipeTestHelpers.CreateClientStream(connectionListener.EndPoint); + await clientStream.ConnectAsync().DefaultTimeout(); + await clientStream.WriteAsync(TestData).DefaultTimeout(); + + var serverConnection = await connectionListener.AcceptAsync().DefaultTimeout(); + var readResult = await serverConnection.Transport.Input.ReadAtLeastAsync(TestData.Length).DefaultTimeout(); + serverConnection.Transport.Input.AdvanceTo(readResult.Buffer.End); + + serverConnection.Abort(new ConnectionAbortedException("Test reason")); + + var serverEx = await Assert.ThrowsAsync(() => serverConnection.Transport.Input.ReadAsync().AsTask()).DefaultTimeout(); + Assert.Equal("Test reason", serverEx.Message); + + // Complete writing. + await serverConnection.Transport.Output.CompleteAsync(); + } + + [Fact] + public async Task InputReadAsync_ServerAbortedDuring_ThrowError() + { + // Arrange + await using var connectionListener = await NamedPipeTestHelpers.CreateConnectionListenerFactory(LoggerFactory); + + // Act + var clientStream = NamedPipeTestHelpers.CreateClientStream(connectionListener.EndPoint); + await clientStream.ConnectAsync().DefaultTimeout(); + await clientStream.WriteAsync(TestData).DefaultTimeout(); + + var serverConnection = await connectionListener.AcceptAsync().DefaultTimeout(); + var readResult = await serverConnection.Transport.Input.ReadAtLeastAsync(TestData.Length).DefaultTimeout(); + serverConnection.Transport.Input.AdvanceTo(readResult.Buffer.End); + + var serverReadTask = serverConnection.Transport.Input.ReadAsync(); + Assert.False(serverReadTask.IsCompleted); + + serverConnection.Abort(new ConnectionAbortedException("Test reason")); + + var serverEx = await Assert.ThrowsAsync(() => serverReadTask.AsTask()).DefaultTimeout(); + Assert.Equal("Test reason", serverEx.Message); + + // Complete writing. + await serverConnection.Transport.Output.CompleteAsync(); + } + + [Fact] + public async Task OutputWriteAsync_ServerAborted_ThrowError() + { + // Arrange + await using var connectionListener = await NamedPipeTestHelpers.CreateConnectionListenerFactory(LoggerFactory); + + // Act + var clientStream = NamedPipeTestHelpers.CreateClientStream(connectionListener.EndPoint); + await clientStream.ConnectAsync().DefaultTimeout(); + await clientStream.WriteAsync(TestData).DefaultTimeout(); + + var serverConnection = await connectionListener.AcceptAsync().DefaultTimeout(); + var readResult = await serverConnection.Transport.Input.ReadAtLeastAsync(TestData.Length).DefaultTimeout(); + serverConnection.Transport.Input.AdvanceTo(readResult.Buffer.End); + + serverConnection.Abort(new ConnectionAbortedException("Test reason")); + + // Write after abort is ignored. + await serverConnection.Transport.Output.WriteAsync(Encoding.UTF8.GetBytes(new string('c', 1024 * 1024 * 10))); + + // Complete writing. + await serverConnection.Transport.Output.CompleteAsync(); + } +} diff --git a/src/Servers/Kestrel/Transport.NamedPipes/test/NamedPipeTestHelpers.cs b/src/Servers/Kestrel/Transport.NamedPipes/test/NamedPipeTestHelpers.cs new file mode 100644 index 000000000000..66a58002f187 --- /dev/null +++ b/src/Servers/Kestrel/Transport.NamedPipes/test/NamedPipeTestHelpers.cs @@ -0,0 +1,90 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.IO.Pipes; +using System.Net; +using System.Security.Principal; +using System.Text; +using Microsoft.AspNetCore.Connections; +using Microsoft.AspNetCore.Server.Kestrel.Transport.NamedPipes.Internal; +using Microsoft.AspNetCore.Testing; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.Server.Kestrel.Transport.NamedPipes.Tests; + +internal static class NamedPipeTestHelpers +{ + private static readonly byte[] TestData = Encoding.UTF8.GetBytes("Hello world"); + + public static string GetUniquePipeName() => "Kestrel-" + Path.GetRandomFileName(); + + public static NamedPipeTransportFactory CreateTransportFactory( + ILoggerFactory loggerFactory = null, + NamedPipeTransportOptions options = null) + { + options ??= new NamedPipeTransportOptions(); + return new NamedPipeTransportFactory(loggerFactory ?? NullLoggerFactory.Instance, Options.Create(options)); + } + + public static async Task CreateConnectionListenerFactory( + ILoggerFactory loggerFactory = null, + string pipeName = null, + NamedPipeTransportOptions options = null) + { + var transportFactory = CreateTransportFactory(loggerFactory, options); + + var endpoint = new NamedPipeEndPoint(pipeName ?? GetUniquePipeName()); + + var listener = (NamedPipeConnectionListener)await transportFactory.BindAsync(endpoint, cancellationToken: CancellationToken.None); + return listener; + } + + public static NamedPipeClientStream CreateClientStream(EndPoint remoteEndPoint, TokenImpersonationLevel? impersonationLevel = null) + { + var namedPipeEndPoint = (NamedPipeEndPoint)remoteEndPoint; + var clientStream = new NamedPipeClientStream( + serverName: namedPipeEndPoint.ServerName, + pipeName: namedPipeEndPoint.PipeName, + direction: PipeDirection.InOut, + options: PipeOptions.WriteThrough | PipeOptions.Asynchronous, + impersonationLevel: impersonationLevel ?? TokenImpersonationLevel.Anonymous); + return clientStream; + } + + public static async Task CreateAndCompleteBidirectionalStreamGracefully(NamedPipeClientStream clientConnection, NamedPipeConnectionListener connectionListener, ILogger logger) + { + logger.LogInformation("Client connecting."); + await clientConnection.ConnectAsync().DefaultTimeout(); + + logger.LogInformation("Server accepting stream."); + var serverConnectionTask = connectionListener.AcceptAsync(); + + logger.LogInformation("Client sending data."); + var writeTask = clientConnection.WriteAsync(TestData); + + var serverConnection = await serverConnectionTask.DefaultTimeout(); + await writeTask.DefaultTimeout(); + + logger.LogInformation("Server reading data."); + var readResult = await serverConnection.Transport.Input.ReadAtLeastAsync(TestData.Length).DefaultTimeout(); + serverConnection.Transport.Input.AdvanceTo(readResult.Buffer.End); + + clientConnection.Close(); + + // Input should be completed. + readResult = await serverConnection.Transport.Input.ReadAsync(); + Assert.True(readResult.IsCompleted); + + // Complete reading and writing. + logger.LogInformation("Server completing input and output."); + await serverConnection.Transport.Input.CompleteAsync(); + await serverConnection.Transport.Output.CompleteAsync(); + + logger.LogInformation("Server disposing connection."); + await serverConnection.DisposeAsync(); + + return Assert.IsType(serverConnection); + } +} diff --git a/src/Servers/Kestrel/Transport.NamedPipes/test/WebHostTests.cs b/src/Servers/Kestrel/Transport.NamedPipes/test/WebHostTests.cs new file mode 100644 index 000000000000..5a2339062a7f --- /dev/null +++ b/src/Servers/Kestrel/Transport.NamedPipes/test/WebHostTests.cs @@ -0,0 +1,384 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.IO.Pipes; +using System.Net; +using System.Net.Http; +using System.Net.Security; +using System.Security.AccessControl; +using System.Security.Principal; +using System.Text; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Connections; +using Microsoft.AspNetCore.Connections.Features; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Internal; +using Microsoft.AspNetCore.Server.Kestrel.Core; +using Microsoft.AspNetCore.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Server.Kestrel.Transport.NamedPipes.Tests; + +public class WebHostTests : LoggedTest +{ + [Fact] + public async Task ListenNamedPipeEndpoint_HelloWorld_ClientSuccess() + { + // Arrange + using var httpEventSource = new HttpEventSourceListener(LoggerFactory); + var pipeName = NamedPipeTestHelpers.GetUniquePipeName(); + + var builder = new HostBuilder() + .ConfigureWebHost(webHostBuilder => + { + webHostBuilder + .UseKestrel(o => + { + o.ListenNamedPipe(pipeName); + }) + .Configure(app => + { + app.Run(async context => + { + await context.Response.WriteAsync("hello, world"); + }); + }); + }) + .ConfigureServices(AddTestLogging); + + using (var host = builder.Build()) + using (var client = CreateClient(pipeName)) + { + await host.StartAsync().DefaultTimeout(); + + var request = new HttpRequestMessage(HttpMethod.Get, $"http://127.0.0.1/") + { + Version = HttpVersion.Version11, + VersionPolicy = HttpVersionPolicy.RequestVersionExact + }; + + // Act + var response = await client.SendAsync(request).DefaultTimeout(); + + // Assert + response.EnsureSuccessStatusCode(); + Assert.Equal(HttpVersion.Version11, response.Version); + var responseText = await response.Content.ReadAsStringAsync().DefaultTimeout(); + Assert.Equal("hello, world", responseText); + + await host.StopAsync().DefaultTimeout(); + } + } + + [ConditionalFact] + [OSSkipCondition(OperatingSystems.Linux | OperatingSystems.MacOSX, SkipReason = "Impersonation is only supported on Windows.")] + public async Task ListenNamedPipeEndpoint_Impersonation_ClientSuccess() + { + AppDomain.CurrentDomain.SetPrincipalPolicy(PrincipalPolicy.WindowsPrincipal); + + // Arrange + using var httpEventSource = new HttpEventSourceListener(LoggerFactory); + var pipeName = NamedPipeTestHelpers.GetUniquePipeName(); + + var builder = new HostBuilder() + .ConfigureWebHost(webHostBuilder => + { + webHostBuilder + .UseKestrel(o => + { + o.ListenNamedPipe(pipeName, listenOptions => + { + listenOptions.Protocols = HttpProtocols.Http1; + }); + }) + .UseNamedPipes(options => + { + var ps = new PipeSecurity(); + ps.AddAccessRule(new PipeAccessRule("Users", PipeAccessRights.ReadWrite | PipeAccessRights.CreateNewInstance, AccessControlType.Allow)); + + options.PipeSecurity = ps; + options.CurrentUserOnly = false; + }) + .Configure(app => + { + app.Run(async context => + { + var serverName = Thread.CurrentPrincipal.Identity.Name; + + var namedPipeStream = context.Features.Get().NamedPipe; + var impersonatedName = namedPipeStream.GetImpersonationUserName(); + + context.Response.Headers.Add("X-Server-Identity", serverName); + context.Response.Headers.Add("X-Impersonated-Identity", impersonatedName); + + var buffer = new byte[1024]; + while (await context.Request.Body.ReadAsync(buffer) != 0) + { + + } + + await context.Response.WriteAsync("hello, world"); + }); + }); + }) + .ConfigureServices(services => + { + AddTestLogging(services); + }); + + using (var host = builder.Build()) + using (var client = CreateClient(pipeName, TokenImpersonationLevel.Impersonation)) + { + await host.StartAsync().DefaultTimeout(); + + var request = new HttpRequestMessage(HttpMethod.Post, $"http://127.0.0.1/") + { + Version = HttpVersion.Version11, + VersionPolicy = HttpVersionPolicy.RequestVersionExact, + Content = new ByteArrayContent(Encoding.UTF8.GetBytes(new string('c', 1024 * 1024))) + }; + + // Act + var response = await client.SendAsync(request).DefaultTimeout(); + + // Assert + response.EnsureSuccessStatusCode(); + Assert.Equal(HttpVersion.Version11, response.Version); + var responseText = await response.Content.ReadAsStringAsync().DefaultTimeout(); + Assert.Equal("hello, world", responseText); + + var serverIdentity = string.Join(",", response.Headers.GetValues("X-Server-Identity")); + var impersonatedIdentity = string.Join(",", response.Headers.GetValues("X-Impersonated-Identity")); + + Assert.Equal(serverIdentity.Split('\\')[1], impersonatedIdentity); + + await host.StopAsync().DefaultTimeout(); + } + } + + [Theory] + [InlineData(HttpProtocols.Http1)] + [InlineData(HttpProtocols.Http2)] + public async Task ListenNamedPipeEndpoint_ProtocolVersion_ClientSuccess(HttpProtocols protocols) + { + // Arrange + using var httpEventSource = new HttpEventSourceListener(LoggerFactory); + var pipeName = NamedPipeTestHelpers.GetUniquePipeName(); + var clientVersion = GetClientVersion(protocols); + + var builder = new HostBuilder() + .ConfigureWebHost(webHostBuilder => + { + webHostBuilder + .UseKestrel(o => + { + o.ListenNamedPipe(pipeName, options => + { + options.Protocols = protocols; + }); + }) + .Configure(app => + { + app.Run(async context => + { + await context.Response.WriteAsync("hello, world"); + }); + }); + }) + .ConfigureServices(AddTestLogging); + + using (var host = builder.Build()) + using (var client = CreateClient(pipeName)) + { + await host.StartAsync().DefaultTimeout(); + + var request = new HttpRequestMessage(HttpMethod.Get, $"http://127.0.0.1/") + { + Version = clientVersion, + VersionPolicy = HttpVersionPolicy.RequestVersionExact + }; + + // Act + var response = await client.SendAsync(request).DefaultTimeout(); + + // Assert + response.EnsureSuccessStatusCode(); + Assert.Equal(clientVersion, response.Version); + var responseText = await response.Content.ReadAsStringAsync().DefaultTimeout(); + Assert.Equal("hello, world", responseText); + + await host.StopAsync().DefaultTimeout(); + } + } + + private static Version GetClientVersion(HttpProtocols protocols) + { + return protocols switch + { + HttpProtocols.Http1 => HttpVersion.Version11, + HttpProtocols.Http2 => HttpVersion.Version20, + _ => throw new InvalidOperationException(), + }; + } + + [ConditionalTheory] + [OSSkipCondition(OperatingSystems.MacOSX, SkipReason = "Missing SslStream ALPN support: https://github.com/dotnet/runtime/issues/27727")] + [InlineData(HttpProtocols.Http1)] + [InlineData(HttpProtocols.Http2)] + public async Task ListenNamedPipeEndpoint_Tls_ClientSuccess(HttpProtocols protocols) + { + // Arrange + using var httpEventSource = new HttpEventSourceListener(LoggerFactory); + var pipeName = NamedPipeTestHelpers.GetUniquePipeName(); + var clientVersion = GetClientVersion(protocols); + + var builder = new HostBuilder() + .ConfigureWebHost(webHostBuilder => + { + webHostBuilder + .UseKestrel(o => + { + o.ListenNamedPipe(pipeName, options => + { + options.Protocols = protocols; + options.UseHttps(TestResources.GetTestCertificate()); + }); + }) + .Configure(app => + { + app.Run(async context => + { + await context.Response.WriteAsync("hello, world"); + }); + }); + }) + .ConfigureServices(AddTestLogging); + + using (var host = builder.Build()) + using (var client = CreateClient(pipeName)) + { + await host.StartAsync().DefaultTimeout(); + + var request = new HttpRequestMessage(HttpMethod.Get, $"https://127.0.0.1/") + { + Version = clientVersion, + VersionPolicy = HttpVersionPolicy.RequestVersionExact + }; + + // Act + var response = await client.SendAsync(request).DefaultTimeout(); + + // Assert + response.EnsureSuccessStatusCode(); + Assert.Equal(clientVersion, response.Version); + var responseText = await response.Content.ReadAsStringAsync().DefaultTimeout(); + Assert.Equal("hello, world", responseText); + + await host.StopAsync().DefaultTimeout(); + } + } + + [Fact] + public async Task ListenNamedPipeEndpoint_FromUrl_HelloWorld_ClientSuccess() + { + // Arrange + using var httpEventSource = new HttpEventSourceListener(LoggerFactory); + var pipeName = NamedPipeTestHelpers.GetUniquePipeName(); + var url = $"http://pipe:/{pipeName}"; + + var builder = new HostBuilder() + .ConfigureWebHost(webHostBuilder => + { + webHostBuilder + .UseUrls(url) + .UseKestrel() + .Configure(app => + { + app.Run(async context => + { + await context.Response.WriteAsync("hello, world"); + }); + }); + }) + .ConfigureServices(AddTestLogging); + + using (var host = builder.Build()) + using (var client = CreateClient(pipeName)) + { + await host.StartAsync().DefaultTimeout(); + + var request = new HttpRequestMessage(HttpMethod.Get, $"http://127.0.0.1/") + { + Version = HttpVersion.Version11, + VersionPolicy = HttpVersionPolicy.RequestVersionExact + }; + + // Act + var response = await client.SendAsync(request).DefaultTimeout(); + + // Assert + response.EnsureSuccessStatusCode(); + Assert.Equal(HttpVersion.Version11, response.Version); + var responseText = await response.Content.ReadAsStringAsync().DefaultTimeout(); + Assert.Equal("hello, world", responseText); + + await host.StopAsync().DefaultTimeout(); + } + + var listeningOn = TestSink.Writes.Single(m => m.EventId.Name == "ListeningOnAddress"); + Assert.Equal($"Now listening on: {url}", listeningOn.Message); + } + + private static HttpClient CreateClient(string pipeName, TokenImpersonationLevel? impersonationLevel = null) + { + var httpHandler = new SocketsHttpHandler + { + SslOptions = new SslClientAuthenticationOptions + { + RemoteCertificateValidationCallback = (_, __, ___, ____) => true + } + }; + + var connectionFactory = new NamedPipesConnectionFactory(pipeName, impersonationLevel); + httpHandler.ConnectCallback = connectionFactory.ConnectAsync; + + return new HttpClient(httpHandler); + } + + public class NamedPipesConnectionFactory + { + private readonly string _pipeName; + private readonly TokenImpersonationLevel? _impersonationLevel; + + public NamedPipesConnectionFactory(string pipeName, TokenImpersonationLevel? impersonationLevel = null) + { + _pipeName = pipeName; + _impersonationLevel = impersonationLevel; + } + + public async ValueTask ConnectAsync(SocketsHttpConnectionContext _, + CancellationToken cancellationToken = default) + { + var clientStream = new NamedPipeClientStream( + serverName: ".", + pipeName: _pipeName, + direction: PipeDirection.InOut, + options: PipeOptions.WriteThrough | PipeOptions.Asynchronous, + impersonationLevel: _impersonationLevel ?? TokenImpersonationLevel.Anonymous); + + try + { + await clientStream.ConnectAsync(cancellationToken).ConfigureAwait(false); + return clientStream; + } + catch + { + clientStream.Dispose(); + throw; + } + } + } +} diff --git a/src/Servers/Kestrel/shared/test/TransportTestHelpers/TestServer.cs b/src/Servers/Kestrel/shared/test/TransportTestHelpers/TestServer.cs index 5b249eb97d8a..ecb9260802a6 100644 --- a/src/Servers/Kestrel/shared/test/TransportTestHelpers/TestServer.cs +++ b/src/Servers/Kestrel/shared/test/TransportTestHelpers/TestServer.cs @@ -94,7 +94,7 @@ public TestServer(RequestDelegate app, TestServiceContext context, Action(), context); + return new KestrelServerImpl(sp.GetServices(), Array.Empty(), context); }); configureServices(services); }) From 6f5e1618f07892c20a4d6f4df72d8a882a598652 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Wed, 21 Dec 2022 13:12:03 +0800 Subject: [PATCH 2/2] Fix solution --- AspNetCore.sln | 3 +++ 1 file changed, 3 insertions(+) diff --git a/AspNetCore.sln b/AspNetCore.sln index 4cb6d6f24eb4..8395a01cb39f 100644 --- a/AspNetCore.sln +++ b/AspNetCore.sln @@ -11438,6 +11438,9 @@ Global {F43CC5EA-6032-4A11-A9B2-6D48CB5EB082} = {4DA84F2B-1948-439B-85AB-E99E31331A9C} {74377D3E-E0C6-41A4-89ED-11A9C00142A9} = {166E48ED-9738-4E13-8618-0D805F6F0F65} {3C7C65BF-0C13-418E-90BD-EC9C3CD282CB} = {74377D3E-E0C6-41A4-89ED-11A9C00142A9} + {F057512B-55BF-4A8B-A027-A0505F8BA10C} = {4FDDC525-4E60-4CAF-83A3-261C5B43721F} + {10173568-A65E-44E5-8C6F-4AA49D0577A1} = {F057512B-55BF-4A8B-A027-A0505F8BA10C} + {97C7D2A4-87E5-4A4A-A170-D736427D5C21} = {F057512B-55BF-4A8B-A027-A0505F8BA10C} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {3E8720B3-DBDD-498C-B383-2CC32A054E8F}