From 22789ec7fdff59ca41f52107417106f5740c426b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 13 May 2026 17:01:16 +0000 Subject: [PATCH] feat(hosting): add maintenance mode middleware + sm down/up (closes #160) Sentinel-file maintenance mode that short-circuits requests with HTTP 503 when active, exempts /health/live and /health/ready, returns JSON for Inertia XHR and the static maintenance.html for browser requests, and supports a ?sm_bypass= query-string handshake that issues an HttpOnly cookie. CLI: sm down --secret X --message "..." --retry 60 [--until ISO]; sm down --status; sm up. Writes/reads App_Data/maintenance.json under the host project. A FileSystemWatcher in the running app picks up changes without restart. Acceptance for #160: - Middleware after auth, before rate limiting / module middleware - Health probes exempted - Inertia request returns JSON 503 with component "System/Maintenance" - Bypass cookie HttpOnly, Secure (https or non-dev), SameSite=Lax - CLI commands wired with examples - 9 middleware tests + 5 sentinel tests, all green Deferred to follow-ups: per-tenant maintenance mode, docs site page, and the System/Maintenance React page (the static HTML fallback is enough for the SPA shell today). --- .gitignore | 1 + .../Commands/Maintenance/DownCommand.cs | 127 +++++++++ .../Commands/Maintenance/DownSettings.cs | 29 ++ .../Maintenance/MaintenanceSentinel.cs | 68 +++++ .../Commands/Maintenance/UpCommand.cs | 33 +++ cli/SimpleModule.Cli/Program.cs | 13 + .../Maintenance/FileMaintenanceModeStore.cs | 178 ++++++++++++ .../Maintenance/IMaintenanceModeStore.cs | 18 ++ .../Maintenance/MaintenanceModeMiddleware.cs | 263 ++++++++++++++++++ .../Maintenance/MaintenanceModeOptions.cs | 16 ++ .../Maintenance/MaintenanceModeState.cs | 21 ++ .../SimpleModuleHostExtensions.cs | 8 + .../wwwroot/maintenance.html | 70 +++++ .../MaintenanceSentinelTests.cs | 73 +++++ .../Hosting/MaintenanceModeMiddlewareTests.cs | 212 ++++++++++++++ 15 files changed, 1130 insertions(+) create mode 100644 cli/SimpleModule.Cli/Commands/Maintenance/DownCommand.cs create mode 100644 cli/SimpleModule.Cli/Commands/Maintenance/DownSettings.cs create mode 100644 cli/SimpleModule.Cli/Commands/Maintenance/MaintenanceSentinel.cs create mode 100644 cli/SimpleModule.Cli/Commands/Maintenance/UpCommand.cs create mode 100644 framework/SimpleModule.Hosting/Maintenance/FileMaintenanceModeStore.cs create mode 100644 framework/SimpleModule.Hosting/Maintenance/IMaintenanceModeStore.cs create mode 100644 framework/SimpleModule.Hosting/Maintenance/MaintenanceModeMiddleware.cs create mode 100644 framework/SimpleModule.Hosting/Maintenance/MaintenanceModeOptions.cs create mode 100644 framework/SimpleModule.Hosting/Maintenance/MaintenanceModeState.cs create mode 100644 template/SimpleModule.Host/wwwroot/maintenance.html create mode 100644 tests/SimpleModule.Cli.Tests/MaintenanceSentinelTests.cs create mode 100644 tests/SimpleModule.Core.Tests/Hosting/MaintenanceModeMiddlewareTests.cs diff --git a/.gitignore b/.gitignore index 892dc204..dab706fe 100644 --- a/.gitignore +++ b/.gitignore @@ -426,6 +426,7 @@ template/SimpleModule.Host/wwwroot/* !template/SimpleModule.Host/wwwroot/favicon.svg !template/SimpleModule.Host/wwwroot/index.html !template/SimpleModule.Host/wwwroot/error.html +!template/SimpleModule.Host/wwwroot/maintenance.html # Tailwind CSS module scan directory template/SimpleModule.Host/Styles/_scan/ diff --git a/cli/SimpleModule.Cli/Commands/Maintenance/DownCommand.cs b/cli/SimpleModule.Cli/Commands/Maintenance/DownCommand.cs new file mode 100644 index 00000000..c8981853 --- /dev/null +++ b/cli/SimpleModule.Cli/Commands/Maintenance/DownCommand.cs @@ -0,0 +1,127 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text.Json; +using SimpleModule.Cli.Infrastructure; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace SimpleModule.Cli.Commands.Maintenance; + +public sealed class DownCommand : Command +{ + public override int Execute(CommandContext context, DownSettings settings) + { + var solution = SolutionContext.Discover(); + if (solution is null) + { + AnsiConsole.MarkupLine( + "[red]Could not find .slnx file. Run this command from within a SimpleModule project.[/]" + ); + return 1; + } + + var sentinelPath = MaintenanceSentinelFile.ResolvePath(solution); + + if (settings.Status) + { + return PrintStatus(sentinelPath); + } + + DateTimeOffset? until = null; + if (!string.IsNullOrWhiteSpace(settings.Until)) + { + if ( + !DateTimeOffset.TryParse( + settings.Until, + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, + out var parsed + ) + ) + { + AnsiConsole.MarkupLine( + $"[red]--until value '{settings.Until}' is not a valid ISO-8601 timestamp.[/]" + ); + return 1; + } + until = parsed; + } + + var secret = settings.Secret ?? GenerateSecret(); + var retryAfter = settings.RetryAfterSeconds is > 0 ? settings.RetryAfterSeconds.Value : 60; + + var sentinel = new MaintenanceSentinel + { + SecretHash = MaintenanceSentinelFile.HashSecret(secret), + Message = settings.Message, + RetryAfterSeconds = retryAfter, + Until = until, + CreatedAt = DateTimeOffset.UtcNow, + }; + + var dir = Path.GetDirectoryName(sentinelPath); + if (!string.IsNullOrEmpty(dir)) + { + Directory.CreateDirectory(dir); + } + + File.WriteAllText( + sentinelPath, + JsonSerializer.Serialize(sentinel, MaintenanceSentinelFile.JsonOptions) + ); + + AnsiConsole.MarkupLine("[green]Application is now in maintenance mode.[/]"); + AnsiConsole.MarkupLine($" Sentinel: [grey]{sentinelPath.EscapeMarkup()}[/]"); + AnsiConsole.MarkupLine($" Bypass URL: [yellow]/?sm_bypass={secret.EscapeMarkup()}[/]"); + if (settings.Secret is null) + { + AnsiConsole.MarkupLine( + " [grey](no --secret provided; a fresh one was generated above)[/]" + ); + } + if (until is { } u) + { + AnsiConsole.MarkupLine( + $" Auto-clears at: [grey]{u.ToString("u", CultureInfo.InvariantCulture).EscapeMarkup()}[/]" + ); + } + + return 0; + } + + private static int PrintStatus(string sentinelPath) + { + var sentinel = MaintenanceSentinelFile.TryRead(sentinelPath); + if (sentinel is null) + { + AnsiConsole.MarkupLine("[green]Application is up.[/]"); + AnsiConsole.MarkupLine($" Sentinel: [grey]{sentinelPath.EscapeMarkup()}[/] (absent)"); + return 0; + } + + AnsiConsole.MarkupLine("[yellow]Application is in maintenance mode.[/]"); + AnsiConsole.MarkupLine($" Sentinel: [grey]{sentinelPath.EscapeMarkup()}[/]"); + AnsiConsole.MarkupLine( + $" Created at: [grey]{sentinel.CreatedAt.ToString("u", CultureInfo.InvariantCulture).EscapeMarkup()}[/]" + ); + AnsiConsole.MarkupLine($" Retry-After: [grey]{sentinel.RetryAfterSeconds}s[/]"); + if (!string.IsNullOrWhiteSpace(sentinel.Message)) + { + AnsiConsole.MarkupLine($" Message: [grey]{sentinel.Message.EscapeMarkup()}[/]"); + } + if (sentinel.Until is { } u) + { + AnsiConsole.MarkupLine( + $" Until: [grey]{u.ToString("u", CultureInfo.InvariantCulture).EscapeMarkup()}[/]" + ); + } + return 0; + } + + private static string GenerateSecret() + { + Span bytes = stackalloc byte[16]; + RandomNumberGenerator.Fill(bytes); + return Convert.ToHexStringLower(bytes); + } +} diff --git a/cli/SimpleModule.Cli/Commands/Maintenance/DownSettings.cs b/cli/SimpleModule.Cli/Commands/Maintenance/DownSettings.cs new file mode 100644 index 00000000..f766283d --- /dev/null +++ b/cli/SimpleModule.Cli/Commands/Maintenance/DownSettings.cs @@ -0,0 +1,29 @@ +using System.ComponentModel; +using Spectre.Console.Cli; + +namespace SimpleModule.Cli.Commands.Maintenance; + +public sealed class DownSettings : CommandSettings +{ + [CommandOption("--secret ")] + [Description( + "Shared secret. Visiting ?sm_bypass= sets a bypass cookie. Generated when omitted." + )] + public string? Secret { get; set; } + + [CommandOption("-m|--message ")] + [Description("Message shown on the maintenance page")] + public string? Message { get; set; } + + [CommandOption("--retry ")] + [Description("Value sent in the Retry-After response header. Defaults to 60")] + public int? RetryAfterSeconds { get; set; } + + [CommandOption("--until ")] + [Description("Optional ISO-8601 timestamp at which maintenance auto-clears (UTC if no offset)")] + public string? Until { get; set; } + + [CommandOption("--status")] + [Description("Print the current maintenance state without modifying it")] + public bool Status { get; set; } +} diff --git a/cli/SimpleModule.Cli/Commands/Maintenance/MaintenanceSentinel.cs b/cli/SimpleModule.Cli/Commands/Maintenance/MaintenanceSentinel.cs new file mode 100644 index 00000000..b9160b59 --- /dev/null +++ b/cli/SimpleModule.Cli/Commands/Maintenance/MaintenanceSentinel.cs @@ -0,0 +1,68 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using SimpleModule.Cli.Infrastructure; + +namespace SimpleModule.Cli.Commands.Maintenance; + +/// +/// File-format mirror of SimpleModule.Hosting.Maintenance.MaintenanceModeState. +/// Kept here so the CLI doesn't have to reference the hosting framework — the JSON +/// shape is the contract between them. +/// +public sealed record MaintenanceSentinel +{ + public string? SecretHash { get; init; } + public string? Message { get; init; } + public int RetryAfterSeconds { get; init; } = 60; + public DateTimeOffset? Until { get; init; } + public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow; +} + +public static class MaintenanceSentinelFile +{ + public static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + public static string ResolvePath(SolutionContext solution) + { + var hostDir = + Path.GetDirectoryName(solution.ApiCsprojPath) + ?? throw new InvalidOperationException( + "Host project directory could not be resolved from solution context." + ); + return Path.Combine(hostDir, "App_Data", "maintenance.json"); + } + + public static string HashSecret(string secret) + { + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(secret)); + return Convert.ToHexStringLower(bytes); + } + + public static MaintenanceSentinel? TryRead(string path) + { + if (!File.Exists(path)) + { + return null; + } + try + { + var json = File.ReadAllText(path); + return JsonSerializer.Deserialize(json, JsonOptions); + } + catch (IOException) + { + return null; + } + catch (JsonException) + { + return null; + } + } +} diff --git a/cli/SimpleModule.Cli/Commands/Maintenance/UpCommand.cs b/cli/SimpleModule.Cli/Commands/Maintenance/UpCommand.cs new file mode 100644 index 00000000..8cab518b --- /dev/null +++ b/cli/SimpleModule.Cli/Commands/Maintenance/UpCommand.cs @@ -0,0 +1,33 @@ +using SimpleModule.Cli.Infrastructure; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace SimpleModule.Cli.Commands.Maintenance; + +public sealed class UpCommand : Command +{ + public override int Execute(CommandContext context, UpSettings settings) + { + var solution = SolutionContext.Discover(); + if (solution is null) + { + AnsiConsole.MarkupLine( + "[red]Could not find .slnx file. Run this command from within a SimpleModule project.[/]" + ); + return 1; + } + + var sentinelPath = MaintenanceSentinelFile.ResolvePath(solution); + if (!File.Exists(sentinelPath)) + { + AnsiConsole.MarkupLine("[green]Application is already up.[/]"); + return 0; + } + + File.Delete(sentinelPath); + AnsiConsole.MarkupLine("[green]Maintenance mode cleared. Application is up.[/]"); + return 0; + } +} + +public sealed class UpSettings : Spectre.Console.Cli.CommandSettings { } diff --git a/cli/SimpleModule.Cli/Program.cs b/cli/SimpleModule.Cli/Program.cs index 9440fba8..29fcc570 100644 --- a/cli/SimpleModule.Cli/Program.cs +++ b/cli/SimpleModule.Cli/Program.cs @@ -2,6 +2,7 @@ using SimpleModule.Cli.Commands.Doctor; using SimpleModule.Cli.Commands.Install; using SimpleModule.Cli.Commands.List; +using SimpleModule.Cli.Commands.Maintenance; using SimpleModule.Cli.Commands.New; using SimpleModule.Cli.Commands.Skill; using SimpleModule.Cli.Commands.Version; @@ -84,6 +85,18 @@ } ); + config + .AddCommand("down") + .WithDescription( + "Put the application into maintenance mode (writes App_Data/maintenance.json)" + ) + .WithExample("down", "--secret", "letmein", "--message", "Migrating database") + .WithExample("down", "--status"); + + config + .AddCommand("up") + .WithDescription("Clear maintenance mode (deletes App_Data/maintenance.json)"); + config.AddCommand("version").WithDescription("Print the sm CLI version"); }); diff --git a/framework/SimpleModule.Hosting/Maintenance/FileMaintenanceModeStore.cs b/framework/SimpleModule.Hosting/Maintenance/FileMaintenanceModeStore.cs new file mode 100644 index 00000000..7fd35a58 --- /dev/null +++ b/framework/SimpleModule.Hosting/Maintenance/FileMaintenanceModeStore.cs @@ -0,0 +1,178 @@ +using System.Text.Json; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace SimpleModule.Hosting.Maintenance; + +/// +/// Sentinel-file backed maintenance store. The sentinel is a JSON document on disk, +/// watched by so the running app picks up changes +/// the CLI makes without restart and without per-request disk I/O. +/// +public sealed class FileMaintenanceModeStore : IMaintenanceModeStore, IDisposable +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + private readonly string _sentinelPath; + private readonly ILogger _logger; + private readonly FileSystemWatcher? _watcher; + private readonly SemaphoreSlim _writeLock = new(1, 1); + private MaintenanceModeState? _cached; + + public FileMaintenanceModeStore( + IOptions options, + IHostEnvironment environment, + ILogger logger + ) + { + _logger = logger; + _sentinelPath = + options.Value.SentinelPath + ?? Path.Combine(environment.ContentRootPath, "App_Data", "maintenance.json"); + + var dir = Path.GetDirectoryName(_sentinelPath); + if (!string.IsNullOrEmpty(dir)) + { + Directory.CreateDirectory(dir); + _watcher = new FileSystemWatcher(dir, Path.GetFileName(_sentinelPath)) + { + NotifyFilter = + NotifyFilters.LastWrite | NotifyFilters.FileName | NotifyFilters.CreationTime, + EnableRaisingEvents = true, + }; + _watcher.Changed += (_, _) => Reload(); + _watcher.Created += (_, _) => Reload(); + _watcher.Deleted += (_, _) => Reload(); + _watcher.Renamed += (_, _) => Reload(); + } + + Reload(); + } + + public MaintenanceModeState? GetState() + { + var state = _cached; + if (state is null) + { + return null; + } + + if (state.Until is { } until && DateTimeOffset.UtcNow >= until) + { + return null; + } + + return state; + } + + public async Task EnableAsync( + MaintenanceModeState state, + CancellationToken cancellationToken = default + ) + { + ArgumentNullException.ThrowIfNull(state); + + var dir = Path.GetDirectoryName(_sentinelPath); + if (!string.IsNullOrEmpty(dir)) + { + Directory.CreateDirectory(dir); + } + + var json = JsonSerializer.Serialize(state, JsonOptions); + + // Serialize the actual write — concurrent callers shouldn't be able to produce + // a torn file. SemaphoreSlim is used (not Monitor) because the body awaits. + await _writeLock.WaitAsync(cancellationToken); + try + { + await File.WriteAllTextAsync(_sentinelPath, json, cancellationToken); + } + finally + { + _writeLock.Release(); + } + + _cached = state; + } + + public async Task DisableAsync(CancellationToken cancellationToken = default) + { + await _writeLock.WaitAsync(cancellationToken); + try + { + if (File.Exists(_sentinelPath)) + { + File.Delete(_sentinelPath); + } + } + finally + { + _writeLock.Release(); + } + + _cached = null; + } + + private void Reload() + { + try + { + if (!File.Exists(_sentinelPath)) + { + _cached = null; + return; + } + + // FileSystemWatcher fires before the writer closes the handle; brief retry + // covers the gap without a sleep loop. + for (var attempt = 0; attempt < 3; attempt++) + { + try + { + var json = File.ReadAllText(_sentinelPath); + if (string.IsNullOrWhiteSpace(json)) + { + _cached = null; + return; + } + + _cached = JsonSerializer.Deserialize(json, JsonOptions); + return; + } + catch (IOException) when (attempt < 2) + { + Thread.Sleep(25); + } + } + } + catch (IOException ex) + { + _logger.LogError( + ex, + "Failed to read maintenance sentinel at {Path}; treating as inactive", + _sentinelPath + ); + _cached = null; + } + catch (JsonException ex) + { + _logger.LogError( + ex, + "Maintenance sentinel at {Path} is not valid JSON; treating as inactive", + _sentinelPath + ); + _cached = null; + } + } + + public void Dispose() + { + _watcher?.Dispose(); + _writeLock.Dispose(); + } +} diff --git a/framework/SimpleModule.Hosting/Maintenance/IMaintenanceModeStore.cs b/framework/SimpleModule.Hosting/Maintenance/IMaintenanceModeStore.cs new file mode 100644 index 00000000..36ba41be --- /dev/null +++ b/framework/SimpleModule.Hosting/Maintenance/IMaintenanceModeStore.cs @@ -0,0 +1,18 @@ +namespace SimpleModule.Hosting.Maintenance; + +/// +/// Reads and writes the maintenance-mode sentinel. Implementations must be safe to call +/// from a hot middleware path; in particular is invoked on every request. +/// +public interface IMaintenanceModeStore +{ + /// + /// Returns the current sentinel snapshot, or null if the application is up. + /// May serve cached data; callers should treat the value as the source of truth. + /// + MaintenanceModeState? GetState(); + + Task EnableAsync(MaintenanceModeState state, CancellationToken cancellationToken = default); + + Task DisableAsync(CancellationToken cancellationToken = default); +} diff --git a/framework/SimpleModule.Hosting/Maintenance/MaintenanceModeMiddleware.cs b/framework/SimpleModule.Hosting/Maintenance/MaintenanceModeMiddleware.cs new file mode 100644 index 00000000..ac979078 --- /dev/null +++ b/framework/SimpleModule.Hosting/Maintenance/MaintenanceModeMiddleware.cs @@ -0,0 +1,263 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using SimpleModule.Core.Constants; +using SimpleModule.Core.Inertia; + +namespace SimpleModule.Hosting.Maintenance; + +/// +/// Short-circuits requests with HTTP 503 when the maintenance sentinel is active, +/// unless the caller presents a valid bypass cookie or visits with a +/// ?sm_bypass=<secret> query string (which sets the cookie and redirects). +/// +public sealed class MaintenanceModeMiddleware +{ + private const string BypassQueryParameter = "sm_bypass"; + + private static readonly JsonSerializerOptions JsonResponseOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + private readonly RequestDelegate _next; + private readonly IMaintenanceModeStore _store; + private readonly MaintenanceModeOptions _options; + private readonly IHostEnvironment _environment; + private readonly Lazy _maintenanceHtml; + + public MaintenanceModeMiddleware( + RequestDelegate next, + IMaintenanceModeStore store, + IOptions options, + IHostEnvironment environment + ) + { + _next = next; + _store = store; + _options = options.Value; + _environment = environment; + _maintenanceHtml = new Lazy(LoadMaintenanceHtml); + } + + public async Task InvokeAsync(HttpContext context) + { + // Health probes always pass through; if the app is in maintenance the load + // balancer still needs to know the process is alive. + var path = context.Request.Path; + if ( + path.StartsWithSegments(RouteConstants.HealthLive, StringComparison.OrdinalIgnoreCase) + || path.StartsWithSegments( + RouteConstants.HealthReady, + StringComparison.OrdinalIgnoreCase + ) + ) + { + await _next(context); + return; + } + + var state = _store.GetState(); + if (state is null) + { + await _next(context); + return; + } + + // ?sm_bypass= → set cookie + redirect to the same URL without the secret in it. + if (context.Request.Query.TryGetValue(BypassQueryParameter, out var providedSecret)) + { + if (state.SecretHash is not null && MatchesSecret(providedSecret.ToString(), state)) + { + IssueBypassCookie(context); + var redirect = StripBypassFromUrl(context.Request); + context.Response.Redirect(redirect); + return; + } + // Wrong secret — fall through to the 503 response. + } + + if (HasValidBypassCookie(context, state)) + { + await _next(context); + return; + } + + await WriteMaintenanceResponseAsync(context, state); + } + + private bool HasValidBypassCookie(HttpContext context, MaintenanceModeState state) + { + if (state.SecretHash is null) + { + return false; + } + + if (!context.Request.Cookies.TryGetValue(_options.BypassCookieName, out var cookieValue)) + { + return false; + } + + return string.Equals(cookieValue, state.SecretHash, StringComparison.Ordinal); + } + + private void IssueBypassCookie(HttpContext context) + { + var state = _store.GetState(); + if (state?.SecretHash is null) + { + return; + } + + context.Response.Cookies.Append( + _options.BypassCookieName, + state.SecretHash, + new CookieOptions + { + HttpOnly = true, + Secure = context.Request.IsHttps || !_environment.IsDevelopment(), + SameSite = SameSiteMode.Lax, + MaxAge = _options.BypassCookieLifetime, + Path = "/", + } + ); + } + + private static string StripBypassFromUrl(HttpRequest request) + { + var remaining = request + .Query.Where(kv => + !string.Equals(kv.Key, BypassQueryParameter, StringComparison.OrdinalIgnoreCase) + ) + .ToList(); + + var path = request.PathBase + request.Path; + if (remaining.Count == 0) + { + return path; + } + + var query = string.Join( + '&', + remaining.Select(kv => + $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value!)}" + ) + ); + return $"{path}?{query}"; + } + + private static bool MatchesSecret(string provided, MaintenanceModeState state) + { + if (string.IsNullOrEmpty(provided) || state.SecretHash is null) + { + return false; + } + + var providedHash = HashSecret(provided); + var providedBytes = Encoding.ASCII.GetBytes(providedHash); + var storedBytes = Encoding.ASCII.GetBytes(state.SecretHash); + if (providedBytes.Length != storedBytes.Length) + { + return false; + } + return CryptographicOperations.FixedTimeEquals(providedBytes, storedBytes); + } + + /// + /// Hashes a bypass secret to the hex form persisted in the sentinel. + /// Shared with the CLI so writes and reads agree on the format. + /// + public static string HashSecret(string secret) + { + ArgumentException.ThrowIfNullOrEmpty(secret); + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(secret)); + // Lowercase hex matches the Convert.ToHexStringLower form the .NET 9+ APIs use + // and is what's persisted in the sentinel; uppercase would break ordinal comparisons. + return Convert.ToHexStringLower(bytes); + } + + private async Task WriteMaintenanceResponseAsync( + HttpContext context, + MaintenanceModeState state + ) + { + context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable; + context.Response.Headers["Retry-After"] = state.RetryAfterSeconds.ToString( + CultureInfo.InvariantCulture + ); + + var isInertia = context.Request.Headers.ContainsKey("X-Inertia"); + var acceptsJson = context.Request.Headers.Accept.Any(a => + a is not null && a.Contains("application/json", StringComparison.OrdinalIgnoreCase) + ); + + if (isInertia || acceptsJson) + { + await WriteJsonAsync(context, state); + return; + } + + await WriteHtmlAsync(context, state); + } + + private static async Task WriteJsonAsync(HttpContext context, MaintenanceModeState state) + { + context.Response.ContentType = "application/json"; + var pageData = new + { + component = "System/Maintenance", + props = new + { + message = state.Message, + retryAfterSeconds = state.RetryAfterSeconds, + until = state.Until, + }, + url = context.Request.Path + context.Request.QueryString, + version = InertiaMiddleware.Version, + }; + + if (context.Request.Headers.ContainsKey("X-Inertia")) + { + context.Response.Headers["X-Inertia"] = "true"; + context.Response.Headers["Vary"] = "X-Inertia"; + } + + await JsonSerializer.SerializeAsync(context.Response.Body, pageData, JsonResponseOptions); + } + + private async Task WriteHtmlAsync(HttpContext context, MaintenanceModeState state) + { + context.Response.ContentType = "text/html; charset=utf-8"; + var html = _maintenanceHtml.Value; + if (html is not null) + { + await context.Response.Body.WriteAsync(html); + return; + } + + var message = string.IsNullOrWhiteSpace(state.Message) + ? "We'll be back shortly." + : System.Net.WebUtility.HtmlEncode(state.Message); + var fallback = + "Maintenance" + + $"

503 Service Unavailable

{message}

"; + await context.Response.WriteAsync(fallback); + } + + private byte[]? LoadMaintenanceHtml() + { + try + { + var path = Path.Combine(_environment.ContentRootPath, "wwwroot", "maintenance.html"); + return File.Exists(path) ? File.ReadAllBytes(path) : null; + } + catch (IOException) + { + return null; + } + } +} diff --git a/framework/SimpleModule.Hosting/Maintenance/MaintenanceModeOptions.cs b/framework/SimpleModule.Hosting/Maintenance/MaintenanceModeOptions.cs new file mode 100644 index 00000000..b6a29a79 --- /dev/null +++ b/framework/SimpleModule.Hosting/Maintenance/MaintenanceModeOptions.cs @@ -0,0 +1,16 @@ +namespace SimpleModule.Hosting.Maintenance; + +public sealed class MaintenanceModeOptions +{ + /// + /// Absolute path to the sentinel file. When unset the store resolves it to + /// {ContentRoot}/App_Data/maintenance.json. + /// + public string? SentinelPath { get; set; } + + /// Name of the bypass cookie. Defaults to sm_bypass. + public string BypassCookieName { get; set; } = "sm_bypass"; + + /// Bypass cookie lifetime once issued. Default 12 hours. + public TimeSpan BypassCookieLifetime { get; set; } = TimeSpan.FromHours(12); +} diff --git a/framework/SimpleModule.Hosting/Maintenance/MaintenanceModeState.cs b/framework/SimpleModule.Hosting/Maintenance/MaintenanceModeState.cs new file mode 100644 index 00000000..feb65853 --- /dev/null +++ b/framework/SimpleModule.Hosting/Maintenance/MaintenanceModeState.cs @@ -0,0 +1,21 @@ +namespace SimpleModule.Hosting.Maintenance; + +/// +/// Snapshot of the maintenance-mode sentinel. null from +/// means the application is up. +/// +public sealed record MaintenanceModeState +{ + /// SHA-256 hex of the bypass secret, or null if no bypass is configured. + public string? SecretHash { get; init; } + + public string? Message { get; init; } + + public int RetryAfterSeconds { get; init; } = 60; + + /// Optional UTC timestamp after which the sentinel should be treated as inactive. + public DateTimeOffset? Until { get; init; } + + /// UTC timestamp the sentinel was created at. + public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow; +} diff --git a/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs b/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs index 00a4782e..fdf39945 100644 --- a/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs +++ b/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs @@ -24,6 +24,7 @@ using SimpleModule.DevTools; using SimpleModule.Hosting.Broadcasting; using SimpleModule.Hosting.Inertia; +using SimpleModule.Hosting.Maintenance; using SimpleModule.Hosting.Middleware; using SimpleModule.Hosting.RateLimiting; using Wolverine; @@ -130,6 +131,9 @@ public static WebApplicationBuilder AddSimpleModuleInfrastructure( builder.Services.AddScoped(); + builder.Services.AddOptions(); + builder.Services.AddSingleton(); + if (options.EnableHealthChecks) { builder @@ -292,6 +296,10 @@ public static async Task UseSimpleModuleInfrastructure(this WebApplication app) app.UseAuthentication(); app.UseAuthorization(); + // Maintenance mode runs after auth so the bypass cookie is set under a known + // identity, but before rate limiting / module middleware so we don't waste + // budget on requests we're going to 503 anyway. + app.UseMiddleware(); app.UseSimpleModuleRateLimiting(); app.UseMiddleware(); diff --git a/template/SimpleModule.Host/wwwroot/maintenance.html b/template/SimpleModule.Host/wwwroot/maintenance.html new file mode 100644 index 00000000..e37811a9 --- /dev/null +++ b/template/SimpleModule.Host/wwwroot/maintenance.html @@ -0,0 +1,70 @@ + + + + + + + We’ll be right back + + + +
+
503 · Service Unavailable
+

We’ll be right back

+

The application is undergoing scheduled maintenance. Please check back shortly.

+
+ + diff --git a/tests/SimpleModule.Cli.Tests/MaintenanceSentinelTests.cs b/tests/SimpleModule.Cli.Tests/MaintenanceSentinelTests.cs new file mode 100644 index 00000000..a503c5d3 --- /dev/null +++ b/tests/SimpleModule.Cli.Tests/MaintenanceSentinelTests.cs @@ -0,0 +1,73 @@ +using System.Text.Json; +using FluentAssertions; +using SimpleModule.Cli.Commands.Maintenance; + +namespace SimpleModule.Cli.Tests; + +public sealed class MaintenanceSentinelTests +{ + [Fact] + public void HashSecret_is_deterministic_lowercase_hex() + { + var a = MaintenanceSentinelFile.HashSecret("opensesame"); + var b = MaintenanceSentinelFile.HashSecret("opensesame"); + + a.Should().Be(b); + a.Should().HaveLength(64); + a.Should().MatchRegex("^[0-9a-f]+$"); + } + + [Fact] + public void HashSecret_differs_per_secret() + { + var a = MaintenanceSentinelFile.HashSecret("alpha"); + var b = MaintenanceSentinelFile.HashSecret("beta"); + + a.Should().NotBe(b); + } + + [Fact] + public void Sentinel_round_trips_through_json() + { + var sentinel = new MaintenanceSentinel + { + SecretHash = MaintenanceSentinelFile.HashSecret("ssh"), + Message = "Migrating", + RetryAfterSeconds = 120, + Until = DateTimeOffset.UtcNow.AddMinutes(5), + }; + + var json = JsonSerializer.Serialize(sentinel, MaintenanceSentinelFile.JsonOptions); + var roundTripped = JsonSerializer.Deserialize( + json, + MaintenanceSentinelFile.JsonOptions + ); + + roundTripped.Should().BeEquivalentTo(sentinel); + json.Should().Contain("secretHash"); + json.Should().Contain("retryAfterSeconds"); + } + + [Fact] + public void TryRead_returns_null_when_file_absent() + { + var bogus = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString() + ".json"); + + MaintenanceSentinelFile.TryRead(bogus).Should().BeNull(); + } + + [Fact] + public void TryRead_returns_null_for_corrupt_file() + { + var path = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString() + ".json"); + File.WriteAllText(path, "{not valid json"); + try + { + MaintenanceSentinelFile.TryRead(path).Should().BeNull(); + } + finally + { + File.Delete(path); + } + } +} diff --git a/tests/SimpleModule.Core.Tests/Hosting/MaintenanceModeMiddlewareTests.cs b/tests/SimpleModule.Core.Tests/Hosting/MaintenanceModeMiddlewareTests.cs new file mode 100644 index 00000000..6058ed16 --- /dev/null +++ b/tests/SimpleModule.Core.Tests/Hosting/MaintenanceModeMiddlewareTests.cs @@ -0,0 +1,212 @@ +using System.Text.Json; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using SimpleModule.Hosting.Maintenance; + +namespace SimpleModule.Core.Tests.Hosting; + +public sealed class MaintenanceModeMiddlewareTests : IDisposable +{ + private readonly string _tempDir; + private readonly string _sentinelPath; + private readonly FileMaintenanceModeStore _store; + private readonly StubHostEnvironment _environment; + + public MaintenanceModeMiddlewareTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), "sm-maintenance-tests-" + Guid.NewGuid()); + Directory.CreateDirectory(_tempDir); + _sentinelPath = Path.Combine(_tempDir, "maintenance.json"); + + _environment = new StubHostEnvironment { ContentRootPath = _tempDir }; + _store = new FileMaintenanceModeStore( + Options.Create(new MaintenanceModeOptions { SentinelPath = _sentinelPath }), + _environment, + NullLogger.Instance + ); + } + + [Fact] + public async Task Passes_through_when_sentinel_absent() + { + var middleware = CreateMiddleware(out var nextCalled); + var ctx = new DefaultHttpContext(); + + await middleware.InvokeAsync(ctx); + + nextCalled().Should().BeTrue(); + ctx.Response.StatusCode.Should().Be(StatusCodes.Status200OK); + } + + [Fact] + public async Task Returns_503_with_retry_after_when_active() + { + await _store.EnableAsync(new MaintenanceModeState { RetryAfterSeconds = 120 }); + var middleware = CreateMiddleware(out var nextCalled); + var ctx = new DefaultHttpContext { Response = { Body = new MemoryStream() } }; + + await middleware.InvokeAsync(ctx); + + nextCalled().Should().BeFalse(); + ctx.Response.StatusCode.Should().Be(StatusCodes.Status503ServiceUnavailable); + ctx.Response.Headers["Retry-After"].ToString().Should().Be("120"); + } + + [Theory] + [InlineData("/health/live")] + [InlineData("/health/ready")] + public async Task Health_endpoints_are_exempted(string path) + { + await _store.EnableAsync(new MaintenanceModeState()); + var middleware = CreateMiddleware(out var nextCalled); + var ctx = new DefaultHttpContext(); + ctx.Request.Path = path; + + await middleware.InvokeAsync(ctx); + + nextCalled().Should().BeTrue(); + ctx.Response.StatusCode.Should().Be(StatusCodes.Status200OK); + } + + [Fact] + public async Task Inertia_requests_get_json_503() + { + await _store.EnableAsync( + new MaintenanceModeState { Message = "Migrating", RetryAfterSeconds = 30 } + ); + var middleware = CreateMiddleware(out _); + var responseBody = new MemoryStream(); + var ctx = new DefaultHttpContext { Response = { Body = responseBody } }; + ctx.Request.Headers["X-Inertia"] = "true"; + + await middleware.InvokeAsync(ctx); + + ctx.Response.StatusCode.Should().Be(StatusCodes.Status503ServiceUnavailable); + ctx.Response.ContentType.Should().Be("application/json"); + ctx.Response.Headers["X-Inertia"].ToString().Should().Be("true"); + ctx.Response.Headers["Vary"].ToString().Should().Be("X-Inertia"); + + responseBody.Position = 0; + var doc = await JsonDocument.ParseAsync(responseBody); + doc.RootElement.GetProperty("component").GetString().Should().Be("System/Maintenance"); + doc.RootElement.GetProperty("props") + .GetProperty("message") + .GetString() + .Should() + .Be("Migrating"); + } + + [Fact] + public async Task Bypass_query_string_sets_cookie_and_redirects() + { + await _store.EnableAsync( + new MaintenanceModeState + { + SecretHash = MaintenanceModeMiddleware.HashSecret("opensesame"), + } + ); + var middleware = CreateMiddleware(out var nextCalled); + var ctx = new DefaultHttpContext { Response = { Body = new MemoryStream() } }; + ctx.Request.Path = "/admin"; + ctx.Request.QueryString = new QueryString("?sm_bypass=opensesame"); + + await middleware.InvokeAsync(ctx); + + nextCalled().Should().BeFalse(); + ctx.Response.StatusCode.Should().Be(StatusCodes.Status302Found); + ctx.Response.Headers["Location"].ToString().Should().Be("/admin"); + + // The Set-Cookie header should carry HttpOnly and SameSite=Lax. + var setCookie = ctx.Response.Headers["Set-Cookie"].ToString(); + setCookie.Should().Contain("sm_bypass="); + setCookie.Should().Contain("httponly", because: "bypass cookie must not be JS-readable"); + setCookie.Should().Contain("samesite=lax"); + } + + [Fact] + public async Task Bypass_query_string_with_wrong_secret_still_503s() + { + await _store.EnableAsync( + new MaintenanceModeState { SecretHash = MaintenanceModeMiddleware.HashSecret("right") } + ); + var middleware = CreateMiddleware(out var nextCalled); + var ctx = new DefaultHttpContext { Response = { Body = new MemoryStream() } }; + ctx.Request.QueryString = new QueryString("?sm_bypass=wrong"); + + await middleware.InvokeAsync(ctx); + + nextCalled().Should().BeFalse(); + ctx.Response.StatusCode.Should().Be(StatusCodes.Status503ServiceUnavailable); + } + + [Fact] + public async Task Valid_bypass_cookie_lets_request_through() + { + var secretHash = MaintenanceModeMiddleware.HashSecret("opensesame"); + await _store.EnableAsync(new MaintenanceModeState { SecretHash = secretHash }); + var middleware = CreateMiddleware(out var nextCalled); + var ctx = new DefaultHttpContext { Response = { Body = new MemoryStream() } }; + ctx.Request.Headers["Cookie"] = $"sm_bypass={secretHash}"; + + await middleware.InvokeAsync(ctx); + + nextCalled().Should().BeTrue(); + } + + [Fact] + public async Task Expired_until_lets_request_through() + { + await _store.EnableAsync( + new MaintenanceModeState { Until = DateTimeOffset.UtcNow.AddSeconds(-1) } + ); + var middleware = CreateMiddleware(out var nextCalled); + var ctx = new DefaultHttpContext(); + + await middleware.InvokeAsync(ctx); + + nextCalled().Should().BeTrue(); + } + + private MaintenanceModeMiddleware CreateMiddleware(out Func wasNextCalled) + { + var called = false; + wasNextCalled = () => called; + RequestDelegate next = _ => + { + called = true; + return Task.CompletedTask; + }; + + return new MaintenanceModeMiddleware( + next, + _store, + Options.Create(new MaintenanceModeOptions { SentinelPath = _sentinelPath }), + _environment + ); + } + + public void Dispose() + { + _store.Dispose(); + try + { + Directory.Delete(_tempDir, recursive: true); + } + catch (IOException) + { + // Test teardown: leave the directory alone on Windows lock contention. + } + } + + private sealed class StubHostEnvironment : IHostEnvironment + { + public string EnvironmentName { get; set; } = "Development"; + public string ApplicationName { get; set; } = "tests"; + public string ContentRootPath { get; set; } = string.Empty; + public Microsoft.Extensions.FileProviders.IFileProvider ContentRootFileProvider { get; set; } = + new Microsoft.Extensions.FileProviders.NullFileProvider(); + } +}