241 lines
9.2 KiB
C#
241 lines
9.2 KiB
C#
// 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;
|
|
|
|
/// <summary>
|
|
/// Provides two extension methods for the <c>.stella-ops.local</c> hostname convention:
|
|
/// <list type="bullet">
|
|
/// <item>
|
|
/// <see cref="TryAddStellaOpsLocalBinding"/> — called on <see cref="WebApplicationBuilder"/>
|
|
/// before <c>Build()</c>; binds both <c>https://{serviceName}.stella-ops.local</c> (port 443)
|
|
/// and <c>http://{serviceName}.stella-ops.local</c> (port 80).
|
|
/// </item>
|
|
/// <item>
|
|
/// <see cref="LogStellaOpsLocalHostname"/> — called on <see cref="WebApplication"/>
|
|
/// after <c>Build()</c>; checks DNS for the friendly hostname and logs the result.
|
|
/// </item>
|
|
/// </list>
|
|
/// </summary>
|
|
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";
|
|
|
|
/// <summary>
|
|
/// Configuration key used to communicate local-binding status
|
|
/// from the builder phase to the app phase.
|
|
/// </summary>
|
|
internal const string LocalBindingBoundKey = "StellaOps:LocalBindingBound";
|
|
|
|
/// <summary>
|
|
/// Configuration key storing the service name for use in the app phase.
|
|
/// </summary>
|
|
internal const string LocalBindingServiceKey = "StellaOps:LocalBindingService";
|
|
|
|
/// <summary>
|
|
/// Resolves <c>{serviceName}.stella-ops.local</c> to its dedicated loopback IP
|
|
/// (from the hosts file), then binds <c>https://{hostname}</c> (port 443) and
|
|
/// <c>http://{hostname}</c> (port 80) on that IP. Each service uses a unique
|
|
/// loopback address (e.g. 127.1.0.2) so ports never collide.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Backwards-compatible overload — reads the service name from configuration
|
|
/// set by <see cref="TryAddStellaOpsLocalBinding"/>.
|
|
/// </summary>
|
|
[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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Registers a startup callback that checks DNS for
|
|
/// <c>{serviceName}.stella-ops.local</c> and logs the result.
|
|
/// Also warns if the local bindings were skipped.
|
|
/// </summary>
|
|
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<IHostApplicationLifetime>();
|
|
|
|
lifetime.ApplicationStarted.Register(() =>
|
|
{
|
|
var logger = app.Services.GetRequiredService<ILoggerFactory>()
|
|
.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;
|
|
}
|
|
}
|
|
}
|