Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/KestrelHttpBinding.cs
T
Joseph Doherty ce9fa07ff8 fix(localdb): re-bind ASPNETCORE_HTTP_PORTS surface, not just ASPNETCORE_URLS
The Kestrel re-bind read only urls/ASPNETCORE_URLS. The aspnet:8.0+ base images
set ASPNETCORE_HTTP_PORTS=8080 (all interfaces) as the CONTAINER default in place
of ASPNETCORE_URLS, so a driver node that never sets URLS fell straight through to
Kestrel's localhost:5000 default — silently moving its health/metrics surface to
loopback when the sync listener took over Kestrel. Now falls through URLS →
HTTP_PORTS/HTTPS_PORTS (wildcard, all-interfaces) → localhost:5000, mirroring
Kestrel's own source precedence. Found on the docker-dev live gate (central nodes
were fine — they set ASPNETCORE_URLS=9000 explicitly).
2026-07-20 22:25:31 -04:00

103 lines
4.9 KiB
C#

using System.Net;
using Microsoft.AspNetCore.Server.Kestrel.Core;
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
/// <summary>
/// One HTTP endpoint the host was asked to serve, parsed out of the configured URL list so it
/// can be re-bound explicitly.
/// </summary>
/// <param name="Host">The host component as written — <c>+</c>, <c>*</c>, a hostname, or an IP.</param>
/// <param name="Port">The port.</param>
/// <param name="IsSecure">True when the URL used the <c>https</c> scheme.</param>
public sealed record KestrelHttpBinding(string Host, int Port, bool IsSecure)
{
/// <summary>
/// Parses a semicolon-separated URL list (the <c>ASPNETCORE_URLS</c> / <c>urls</c> format)
/// into bindings. Unparseable entries are skipped rather than throwing — a malformed URL
/// must not take the process down at startup.
/// </summary>
/// <param name="urls">The configured URL list, or null/empty when none is configured.</param>
/// <returns>The parsed bindings, in the order given; empty when nothing is configured.</returns>
public static IReadOnlyList<KestrelHttpBinding> Parse(string? urls)
{
if (string.IsNullOrWhiteSpace(urls))
return [];
var result = new List<KestrelHttpBinding>();
foreach (var raw in urls.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
// Uri cannot parse the "+"/"*" wildcard hosts Kestrel accepts, so substitute a
// placeholder host purely to get scheme/port out, then keep the original host token.
var isWildcard = raw.Contains("://+", StringComparison.Ordinal)
|| raw.Contains("://*", StringComparison.Ordinal);
var probe = isWildcard
? raw.Replace("://+", "://placeholder", StringComparison.Ordinal)
.Replace("://*", "://placeholder", StringComparison.Ordinal)
: raw;
if (!Uri.TryCreate(probe, UriKind.Absolute, out var uri))
continue;
var host = isWildcard
? raw.Contains("://+", StringComparison.Ordinal) ? "+" : "*"
: uri.Host;
result.Add(new KestrelHttpBinding(host, uri.Port, uri.Scheme == Uri.UriSchemeHttps));
}
return result;
}
/// <summary>
/// Parses the bare-port list format of <c>ASPNETCORE_HTTP_PORTS</c> / <c>HTTP_PORTS</c> (and
/// the <c>HTTPS</c> variants) into wildcard (<c>+</c>, all-interfaces) bindings — the shape
/// Kestrel itself gives those variables. Modern .NET base images (aspnet:8.0+) set
/// <c>ASPNETCORE_HTTP_PORTS=8080</c> as the container default <b>instead of</b>
/// <c>ASPNETCORE_URLS</c>, so a node that never sets <c>URLS</c> explicitly still has a real
/// configured surface here that must be re-bound, not replaced with Kestrel's localhost:5000.
/// </summary>
/// <param name="ports">A <c>;</c>- or <c>,</c>-separated list of bare ports, or null/empty.</param>
/// <param name="isSecure">True to mark the parsed bindings <c>https</c> (the <c>HTTPS_PORTS</c> vars).</param>
/// <returns>One all-interfaces binding per parseable port; empty when nothing is configured.</returns>
public static IReadOnlyList<KestrelHttpBinding> ParsePorts(string? ports, bool isSecure)
{
if (string.IsNullOrWhiteSpace(ports))
return [];
var result = new List<KestrelHttpBinding>();
foreach (var token in ports.Split([';', ','], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
// Skip a malformed port rather than throwing — a bad env var must not down startup.
if (int.TryParse(token, out var port) && port is > 0 and <= 65535)
result.Add(new KestrelHttpBinding("+", port, isSecure));
}
return result;
}
/// <summary>
/// Applies this binding to Kestrel, choosing the narrowest listen call the host component
/// allows.
/// </summary>
/// <remarks>
/// A hostname that is neither a literal IP nor <c>localhost</c> cannot be reliably mapped to
/// a local interface, so it falls back to <see cref="KestrelServerOptions.ListenAnyIP"/> —
/// the safe superset. Narrowing it and guessing wrong would silently stop serving.
/// </remarks>
/// <param name="options">The Kestrel options being configured.</param>
public void Apply(KestrelServerOptions options)
{
ArgumentNullException.ThrowIfNull(options);
if (Host is "+" or "*")
options.ListenAnyIP(Port);
else if (IPAddress.TryParse(Host, out var address))
options.Listen(address, Port);
else if (string.Equals(Host, "localhost", StringComparison.OrdinalIgnoreCase))
options.ListenLocalhost(Port);
else
options.ListenAnyIP(Port);
}
}