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();
}
}
}