using System.Net; using System.Net.Http; using System.Net.Sockets; using System.Reflection; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Xunit; namespace ZB.MOM.WW.ScadaBridge.Host.Tests; /// /// ScadaBridge#108 (F1): central re-binds its HTTP surface from ASPNETCORE_URLS. It used to keep only /// the port, so https://+:443 served plaintext. https must now get TLS from the configured node /// certificate or refuse to boot; http must bind exactly as before. /// public sealed class CentralHttpsBindingTests : IDisposable { private readonly string _dir = Directory.CreateTempSubdirectory("sb108").FullName; /// Removes the generated certificate files. public void Dispose() => Directory.Delete(_dir, recursive: true); private (string pfx, X509Certificate2 cert) MakePfx(string password) { using var rsa = RSA.Create(2048); var req = new CertificateRequest("CN=localhost", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); var san = new SubjectAlternativeNameBuilder(); san.AddDnsName("localhost"); req.CertificateExtensions.Add(san.Build()); using var ephemeral = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddDays(1)); var bytes = ephemeral.Export(X509ContentType.Pfx, password); var path = Path.Combine(_dir, "node.pfx"); File.WriteAllBytes(path, bytes); return (path, X509CertificateLoader.LoadPkcs12(bytes, password)); } /// The scheme survives parsing. [Theory] [InlineData("http://+:5000", 5000, false)] [InlineData("https://+:443", 443, true)] [InlineData("HTTPS://*:8443", 8443, true)] [InlineData("https://localhost", 443, true)] public void Scheme_is_kept(string url, int port, bool https) { Assert.Equal(new HttpBindEndpoint(port, https), Assert.Single(Program.ParseHttpBindEndpoints(url))); } /// One port with two schemes is a config error. [Fact] public void Same_port_with_two_schemes_throws() => Assert.Throws(() => Program.ParseHttpBindEndpoints("http://+:5000;https://+:5000")); /// https without a certificate refuses to boot (the F1 downgrade is closed). [Fact] public void Https_without_certificate_refuses_to_boot() { var eps = Program.ParseHttpBindEndpoints("https://+:443"); var ex = Assert.Throws(() => CentralHttpBinding.LoadCertificateIfNeeded(eps, new NodeTlsOptions())); Assert.Contains("Refusing to boot", ex.Message); } /// Both a path and a thumbprint is ambiguous. [Fact] public void Path_and_thumbprint_together_throw() { var eps = Program.ParseHttpBindEndpoints("https://+:443"); Assert.Throws(() => CentralHttpBinding.LoadCertificateIfNeeded( eps, new NodeTlsOptions { CertificatePath = "x.pfx", CertificateThumbprint = "ABCD" })); } /// http-only URLs ignore the certificate settings and load nothing. [Fact] public void Http_only_loads_no_certificate() { var eps = Program.ParseHttpBindEndpoints("http://+:5000"); Assert.Null(CentralHttpBinding.LoadCertificateIfNeeded(eps, new NodeTlsOptions { CertificatePath = "missing.pfx" })); } private static List<(string endpoint, HttpProtocols protocols, bool tls)> Effective(Action configure) { var options = new KestrelServerOptions { ApplicationServices = new ServiceCollection().AddLogging().BuildServiceProvider(), }; configure(options); var prop = typeof(KestrelServerOptions).GetProperty("CodeBackedListenOptions", BindingFlags.Instance | BindingFlags.NonPublic)!; var isTls = typeof(ListenOptions).GetProperty("IsTls", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public)!; return ((System.Collections.IEnumerable)prop.GetValue(options)!).Cast() .Select(l => (l.IPEndPoint!.ToString(), l.Protocols, (bool)isTls.GetValue(l)!)).ToList(); } /// /// http URLs produce the same effective Kestrel endpoint configuration as the pre-#108 inline /// loop (ListenAnyIP + Http1AndHttp2, no TLS). /// [Fact] public void Http_endpoints_are_identical_to_the_legacy_binding() { const string urls = "http://+:5000;http://*:8085"; var legacy = Effective(o => { foreach (var port in Program.ParseHttpBindPorts(urls)) { o.ListenAnyIP(port, l => l.Protocols = HttpProtocols.Http1AndHttp2); } }); var current = Effective(o => CentralHttpBinding.BindHttpSurface(o, Program.ParseHttpBindEndpoints(urls), null)); Assert.Equal(2, current.Count); Assert.Equal(legacy, current); Assert.All(current, c => Assert.False(c.tls)); } private static int FreePort() { var l = new TcpListener(IPAddress.Loopback, 0); l.Start(); var p = ((IPEndPoint)l.LocalEndpoint).Port; l.Stop(); return p; } /// /// A real Kestrel listener bound through the production path (certificate loaded from a .pfx + /// password) serves TLS on the https port with HTTP/1.1 and h2 by ALPN, and a plaintext client /// cannot talk to it; the http port still serves plaintext. /// [Fact] public async Task Real_https_listener_serves_tls_and_http_stays_plaintext() { var (pfx, expected) = MakePfx("pw"); int httpsPort = FreePort(), httpPort = FreePort(); var eps = Program.ParseHttpBindEndpoints($"https://+:{httpsPort};http://+:{httpPort}"); var loaded = CentralHttpBinding.LoadCertificateIfNeeded(eps, new NodeTlsOptions { CertificatePath = pfx, CertificatePassword = "pw" }); Assert.Equal(expected.Thumbprint, loaded!.Thumbprint); using var host = await new HostBuilder() .ConfigureWebHost(web => web .UseKestrel(k => CentralHttpBinding.BindHttpSurface(k, eps, loaded)) .Configure(app => app.Run(ctx => ctx.Response.WriteAsync($"{ctx.Request.Scheme}/{ctx.Request.Protocol}")))) .StartAsync(); try { using var handler = new SocketsHttpHandler { SslOptions = { RemoteCertificateValidationCallback = (_, c, _, _) => c?.GetCertHashString() == expected.Thumbprint }, }; using var client = new HttpClient(handler); using var h2 = new HttpRequestMessage(HttpMethod.Get, $"https://localhost:{httpsPort}/") { Version = HttpVersion.Version20, VersionPolicy = HttpVersionPolicy.RequestVersionExact }; Assert.Equal("https/HTTP/2", await (await client.SendAsync(h2)).Content.ReadAsStringAsync()); // ALPN h2 using var h11 = new HttpRequestMessage(HttpMethod.Get, $"https://localhost:{httpsPort}/") { Version = HttpVersion.Version11, VersionPolicy = HttpVersionPolicy.RequestVersionExact }; Assert.Equal("https/HTTP/1.1", await (await client.SendAsync(h11)).Content.ReadAsStringAsync()); // Plaintext to the TLS port must fail (it is not silently plain HTTP any more). await Assert.ThrowsAnyAsync(() => client.GetStringAsync($"http://localhost:{httpsPort}/")); Assert.Equal("http/HTTP/1.1", await client.GetStringAsync($"http://localhost:{httpPort}/")); } finally { await host.StopAsync(); } } }