// Copyright (c) StellaOps. All rights reserved. // Licensed under BUSL-1.1. See LICENSE in the project root. using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using System; using System.Collections.Generic; using System.Linq; using System.Net; using System.Net.Sockets; using System.Runtime.InteropServices; using System.Security.Cryptography.X509Certificates; using System.Threading; using System.Threading.Tasks; namespace StellaOps.Auth.ServerIntegration; /// /// Provides two extension methods for the .stella-ops.local hostname convention: /// /// /// — called on /// before Build(); binds both https://{serviceName}.stella-ops.local (port 443) /// and http://{serviceName}.stella-ops.local (port 80). /// /// /// — called on /// after Build(); checks DNS for the friendly hostname and logs the result. /// /// /// public static class StellaOpsLocalHostnameExtensions { private const string DomainSuffix = ".stella-ops.local"; private const int HttpsPort = 443; private const int HttpPort = 80; private const string SetupDocPath = "docs/technical/architecture/port-registry.md"; /// /// Configuration key used to communicate local-binding status /// from the builder phase to the app phase. /// internal const string LocalBindingBoundKey = "StellaOps:LocalBindingBound"; /// /// Configuration key storing the service name for use in the app phase. /// internal const string LocalBindingServiceKey = "StellaOps:LocalBindingService"; /// /// Resolves {serviceName}.stella-ops.local to its dedicated loopback IP /// (from the hosts file), then binds https://{hostname} (port 443) and /// http://{hostname} (port 80) on that IP. Each service uses a unique /// loopback address (e.g. 127.1.0.2) so ports never collide. /// public static WebApplicationBuilder TryAddStellaOpsLocalBinding( this WebApplicationBuilder builder, string serviceName) { builder.Configuration[LocalBindingServiceKey] = serviceName; var hostname = serviceName + DomainSuffix; // Resolve the hostname to find its dedicated loopback IP from the hosts file. IPAddress? resolvedIp = null; try { var addresses = Dns.GetHostAddresses(hostname); resolvedIp = addresses.FirstOrDefault(a => a.AddressFamily == AddressFamily.InterNetwork); } catch { // Hostname does not resolve; skip binding (will warn at startup). } if (resolvedIp == null) { builder.Configuration[LocalBindingBoundKey] = "false"; return builder; } var httpsAvailable = IsPortAvailable(HttpsPort, resolvedIp); var httpAvailable = IsPortAvailable(HttpPort, resolvedIp); if (!httpsAvailable && !httpAvailable) { builder.Configuration[LocalBindingBoundKey] = "false"; return builder; } builder.Configuration[LocalBindingBoundKey] = "true"; // Bind to the specific loopback IP (not hostname) so Kestrel uses only // this address, leaving other 127.1.0.x IPs available for other services. // UseUrls("https://hostname") would bind to [::]:443 (all interfaces). // // When ConfigureKestrel uses explicit Listen() calls, Kestrel ignores UseUrls. // So we must also re-add the dev-port bindings from launchSettings.json. var currentUrls = builder.WebHost.GetSetting(WebHostDefaults.ServerUrlsKey) ?? ""; var ip = resolvedIp; builder.WebHost.ConfigureKestrel((context, kestrel) => { // Re-add dev-port bindings from launchSettings.json / ASPNETCORE_URLS foreach (var rawUrl in currentUrls.Split(';', StringSplitOptions.RemoveEmptyEntries)) { if (Uri.TryCreate(rawUrl.Trim(), UriKind.Absolute, out var uri)) { var isHttps = string.Equals(uri.Scheme, "https", StringComparison.OrdinalIgnoreCase); var addr = uri.Host == "localhost" ? IPAddress.Loopback : IPAddress.Parse(uri.Host); if (isHttps) { kestrel.Listen(addr, uri.Port, lo => lo.UseHttps()); } else { kestrel.Listen(addr, uri.Port); } } } // Add .stella-ops.local bindings on the dedicated loopback IP if (httpsAvailable) { kestrel.Listen(ip, HttpsPort, listenOptions => { listenOptions.UseHttps(); }); } if (httpAvailable) { kestrel.Listen(ip, HttpPort); } }); return builder; } /// /// Backwards-compatible overload — reads the service name from configuration /// set by . /// [System.Obsolete("Use TryAddStellaOpsLocalBinding(builder, serviceName) instead of TryAddStellaOpsSharedPort(). This overload will be removed.")] public static WebApplicationBuilder TryAddStellaOpsSharedPort( this WebApplicationBuilder builder) { // No-op fallback for any callers not yet migrated; the binding happens // in TryAddStellaOpsLocalBinding which should be called instead. return builder; } /// /// Registers a startup callback that checks DNS for /// {serviceName}.stella-ops.local and logs the result. /// Also warns if the local bindings were skipped. /// public static WebApplication LogStellaOpsLocalHostname( this WebApplication app, string serviceName) { var hostname = serviceName + DomainSuffix; var localBindingBound = string.Equals( app.Configuration[LocalBindingBoundKey], "true", StringComparison.OrdinalIgnoreCase); var lifetime = app.Services.GetRequiredService(); lifetime.ApplicationStarted.Register(() => { var logger = app.Services.GetRequiredService() .CreateLogger("StellaOps.LocalHostname"); if (!localBindingBound) { logger.LogWarning( "Ports {HttpsPort}/{HttpPort} are already in use; skipping {Hostname} bindings. " + "This is expected when running multiple services locally.", HttpsPort, HttpPort, hostname); } _ = Task.Run(async () => { try { using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); var resolved = await Dns.GetHostAddressesAsync(hostname, cts.Token); if (resolved.Length > 0) { if (localBindingBound) { logger.LogInformation( "Also accessible at https://{Hostname} and http://{Hostname}", hostname, hostname); } else { logger.LogInformation( "Hostname {Hostname} resolves but ports {HttpsPort}/{HttpPort} are unavailable; " + "use the dev port instead.", hostname, HttpsPort, HttpPort); } } } catch { var hostsPath = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? @"C:\Windows\System32\drivers\etc\hosts" : "/etc/hosts"; logger.LogWarning( "Hostname {Hostname} does not resolve. " + "To enable friendly .stella-ops.local URLs, add hosts-file entries " + "as described in {SetupDoc}. " + "Edit {HostsFile} and add a unique 127.1.0.x entry for {Hostname}", hostname, SetupDocPath, hostsPath, hostname); } }); }); return app; } private static bool IsPortAvailable(int port, IPAddress address) { try { using var listener = new TcpListener(address, port); listener.Start(); listener.Stop(); return true; } catch { return false; } } }