diff --git a/YARP.slnx b/YARP.slnx index 8038a9727..173923337 100644 --- a/YARP.slnx +++ b/YARP.slnx @@ -32,6 +32,10 @@ + + + + diff --git a/samples/README.md b/samples/README.md index 041479614..4f385476a 100644 --- a/samples/README.md +++ b/samples/README.md @@ -27,3 +27,4 @@ The following samples are provided: | [Lets Encrypt](ReverseProxy.LetsEncrypt.Sample) | Shows how to use a certificate authority such as Lets Encrypt to set up TLS termination in YARP. | | [Kubernetes Ingress](KubernetesIngress.Sample) | Shows how to use YARP as a Kubernetes ingress controller | | [Prometheus](Prometheus) | Shows how to consume the YARP telemetry library and export metrics to external telemetry such as Prometheus | +| [Static Site](StaticSite) | Shows how to use the YARP container application as a static file host with SPA fallback | diff --git a/samples/StaticSite/README.md b/samples/StaticSite/README.md new file mode 100644 index 000000000..9646d86eb --- /dev/null +++ b/samples/StaticSite/README.md @@ -0,0 +1,23 @@ +# Static Site Sample + +A minimal static site served by the YARP container application. + +## Run + +```bash +# From the repo root +dotnet run --project src/Application/Yarp.Application.csproj -- samples/StaticSite/yarp-config.json +``` + +Then open http://localhost:5000 + +## What's in the box + +```text +wwwroot/ + index.html # Home page + 404.html # Custom error page + _astro/main.a1b2c3.css # Hashed asset (simulates Astro build output) + docs/getting-started/index.html # Nested page with directory default document +yarp-config.json # YARP container configuration +``` diff --git a/samples/StaticSite/wwwroot/404.html b/samples/StaticSite/wwwroot/404.html new file mode 100644 index 000000000..a7e731166 --- /dev/null +++ b/samples/StaticSite/wwwroot/404.html @@ -0,0 +1,13 @@ + + + + + 404 - Page Not Found + + + +

404 - Page Not Found

+

The page you're looking for doesn't exist.

+ Go home + + diff --git a/samples/StaticSite/wwwroot/_astro/main.a1b2c3.css b/samples/StaticSite/wwwroot/_astro/main.a1b2c3.css new file mode 100644 index 000000000..4677c9d65 --- /dev/null +++ b/samples/StaticSite/wwwroot/_astro/main.a1b2c3.css @@ -0,0 +1,10 @@ +body { + font-family: system-ui, -apple-system, sans-serif; + max-width: 800px; + margin: 0 auto; + padding: 2rem; + color: #333; +} +nav { margin-bottom: 2rem; } +nav a { margin-right: 1rem; } +h1 { color: #512bd4; } diff --git a/samples/StaticSite/wwwroot/docs/getting-started/index.html b/samples/StaticSite/wwwroot/docs/getting-started/index.html new file mode 100644 index 000000000..ad7b143a9 --- /dev/null +++ b/samples/StaticSite/wwwroot/docs/getting-started/index.html @@ -0,0 +1,12 @@ + + + + + Getting Started - YARP + + + +

Getting Started

+

Learn how to use YARP as a static file host and reverse proxy.

+ + diff --git a/samples/StaticSite/wwwroot/index.html b/samples/StaticSite/wwwroot/index.html new file mode 100644 index 000000000..3ec5b6a3a --- /dev/null +++ b/samples/StaticSite/wwwroot/index.html @@ -0,0 +1,18 @@ + + + + + + YARP Static Site + + + + +

Welcome to YARP

+

This is a static site served by the YARP container.

+ + diff --git a/samples/StaticSite/yarp-config.json b/samples/StaticSite/yarp-config.json new file mode 100644 index 000000000..45a69ca7c --- /dev/null +++ b/samples/StaticSite/yarp-config.json @@ -0,0 +1,11 @@ +{ + "$schema": "../../src/Application/yarp-config.schema.json", + + "StaticFiles": { + "Enabled": true + }, + + "NavigationFallback": { + "Path": "/index.html" + } +} diff --git a/src/Application/Configuration/NavigationFallbackOptions.cs b/src/Application/Configuration/NavigationFallbackOptions.cs new file mode 100644 index 000000000..fb724e118 --- /dev/null +++ b/src/Application/Configuration/NavigationFallbackOptions.cs @@ -0,0 +1,9 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Yarp.Application.Configuration; + +public sealed class NavigationFallbackOptions +{ + public string? Path { get; set; } +} diff --git a/src/Application/Configuration/StaticFilesOptions.cs b/src/Application/Configuration/StaticFilesOptions.cs new file mode 100644 index 000000000..12d4461f4 --- /dev/null +++ b/src/Application/Configuration/StaticFilesOptions.cs @@ -0,0 +1,9 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Yarp.Application.Configuration; + +public sealed class StaticFilesOptions +{ + public bool Enabled { get; set; } +} diff --git a/src/Application/Configuration/TelemetryOptions.cs b/src/Application/Configuration/TelemetryOptions.cs new file mode 100644 index 000000000..56401dda5 --- /dev/null +++ b/src/Application/Configuration/TelemetryOptions.cs @@ -0,0 +1,9 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Yarp.Application.Configuration; + +public sealed class TelemetryOptions +{ + public bool UnsafeAcceptAnyCertificate { get; set; } +} diff --git a/src/Application/Configuration/TestConfiguration.cs b/src/Application/Configuration/TestConfiguration.cs new file mode 100644 index 000000000..314e3d104 --- /dev/null +++ b/src/Application/Configuration/TestConfiguration.cs @@ -0,0 +1,31 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Extensions.Configuration; + +/// +/// Test hook that allows WebApplicationFactory to inject configuration +/// before the app binds its config model. +/// Uses AsyncLocal to flow state from test to app without shared mutable state. +/// See: https://github.com/dotnet/aspnetcore/issues/37680 +/// +internal static class TestConfiguration +{ + internal static readonly AsyncLocal?> _current = new(); + + public static IConfigurationBuilder AddTestConfiguration(this IConfigurationBuilder configurationBuilder) + { + if (_current.Value is { } configure) + { + configure(configurationBuilder); + _current.Value = null; + } + + return configurationBuilder; + } + + public static void Create(Action action) + { + _current.Value = action; + } +} diff --git a/src/Application/Configuration/YarpAppConfig.cs b/src/Application/Configuration/YarpAppConfig.cs new file mode 100644 index 000000000..c55e0439f --- /dev/null +++ b/src/Application/Configuration/YarpAppConfig.cs @@ -0,0 +1,15 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Yarp.Application.Configuration; + +/// +/// Root configuration for the YARP container application. +/// Bound from IConfiguration at startup — all application code uses this object model. +/// +public sealed class YarpAppConfig +{ + public StaticFilesOptions StaticFiles { get; set; } = new(); + public NavigationFallbackOptions NavigationFallback { get; set; } = new(); + public TelemetryOptions Telemetry { get; set; } = new(); +} diff --git a/src/Application/Configuration/YarpAppConfigBinder.cs b/src/Application/Configuration/YarpAppConfigBinder.cs new file mode 100644 index 000000000..5ecffec4e --- /dev/null +++ b/src/Application/Configuration/YarpAppConfigBinder.cs @@ -0,0 +1,60 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Configuration; + +namespace Yarp.Application.Configuration; + +/// +/// Binds IConfiguration into the YarpAppConfig object model. +/// This is the single conversion point — all application code uses the resulting object model. +/// +public static class YarpAppConfigBinder +{ + public static YarpAppConfig Bind(IConfiguration configuration) + { + var config = new YarpAppConfig(); + + configuration.GetSection(nameof(config.StaticFiles)).Bind(config.StaticFiles); + configuration.GetSection(nameof(config.NavigationFallback)).Bind(config.NavigationFallback); + configuration.GetSection(nameof(config.Telemetry)).Bind(config.Telemetry); + + // Legacy env var support + MapLegacyKeys(configuration, config); + + return config; + } + + private static void MapLegacyKeys(IConfiguration configuration, YarpAppConfig config) + { + // YARP_ENABLE_STATIC_FILES -> StaticFiles.Enabled + if (!config.StaticFiles.Enabled + && string.Equals(configuration["YARP_ENABLE_STATIC_FILES"], "true", StringComparison.OrdinalIgnoreCase)) + { + config.StaticFiles.Enabled = true; + } + + // YARP_DISABLE_SPA_FALLBACK -> NavigationFallback.Path = null + // Legacy: when static files were enabled, SPA fallback defaulted to on. + // If the new config sets NavigationFallback.Path, that takes precedence. + if (config.StaticFiles.Enabled + && config.NavigationFallback.Path is null + && !string.Equals(configuration["YARP_DISABLE_SPA_FALLBACK"], "true", StringComparison.OrdinalIgnoreCase)) + { + // Legacy behavior: SPA fallback was on by default when static files were enabled + // Only apply if using legacy keys (no explicit NavigationFallback section) + if (!configuration.GetSection(nameof(config.NavigationFallback)).Exists() + && string.Equals(configuration["YARP_ENABLE_STATIC_FILES"], "true", StringComparison.OrdinalIgnoreCase)) + { + config.NavigationFallback.Path = "/index.html"; + } + } + + // YARP_UNSAFE_OLTP_CERT_ACCEPT_ANY_SERVER_CERTIFICATE -> Telemetry.UnsafeAcceptAnyCertificate + if (!config.Telemetry.UnsafeAcceptAnyCertificate + && string.Equals(configuration["YARP_UNSAFE_OLTP_CERT_ACCEPT_ANY_SERVER_CERTIFICATE"], "true", StringComparison.OrdinalIgnoreCase)) + { + config.Telemetry.UnsafeAcceptAnyCertificate = true; + } + } +} diff --git a/src/Application/Extensions.cs b/src/Application/Extensions.cs index d12a6440c..cbaca4a97 100644 --- a/src/Application/Extensions.cs +++ b/src/Application/Extensions.cs @@ -11,18 +11,15 @@ using OpenTelemetry.Logs; using OpenTelemetry.Metrics; using OpenTelemetry.Trace; -using static System.Net.WebRequestMethods; +using Yarp.Application.Configuration; namespace Microsoft.Extensions.Hosting; -// Adds common .NET Aspire services: service discovery, resilience, health checks, and OpenTelemetry. -// This project should be referenced by each service project in your solution. -// To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults public static class Extensions { - public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder + public static TBuilder AddServiceDefaults(this TBuilder builder, YarpAppConfig config) where TBuilder : IHostApplicationBuilder { - builder.ConfigureOpenTelemetry(); + builder.ConfigureOpenTelemetry(config); builder.AddDefaultHealthChecks(); @@ -31,7 +28,7 @@ public static TBuilder AddServiceDefaults(this TBuilder builder) where return builder; } - public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) where TBuilder : IHostApplicationBuilder + public static TBuilder ConfigureOpenTelemetry(this TBuilder builder, YarpAppConfig config) where TBuilder : IHostApplicationBuilder { var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); @@ -62,7 +59,7 @@ public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) w .AddOtlpExporter(); }); - if (string.Equals(builder.Configuration["YARP_UNSAFE_OLTP_CERT_ACCEPT_ANY_SERVER_CERTIFICATE"], "true", StringComparison.OrdinalIgnoreCase)) + if (config.Telemetry.UnsafeAcceptAnyCertificate) { // We cannot use UseOtlpExporter() since it doesn't support configuration via OtlpExporterOptions // https://github.com/open-telemetry/opentelemetry-dotnet/issues/5802 diff --git a/src/Application/Features/LoggingFeature.cs b/src/Application/Features/LoggingFeature.cs new file mode 100644 index 000000000..cf1220d1b --- /dev/null +++ b/src/Application/Features/LoggingFeature.cs @@ -0,0 +1,81 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Console; +using Yarp.Application.Configuration; + +namespace Yarp.Application.Features; + +public static class LoggingFeature +{ + /// + /// Suppress noisy framework logs on the console provider in default mode. + /// Other providers (OTEL, etc.) still receive everything. + /// + public static ILoggingBuilder ConfigureDefaultLogging(this ILoggingBuilder logging, IConfiguration configuration) + { + // Suppress noisy framework logs on console by default + logging.AddFilter("Microsoft.AspNetCore", LogLevel.Warning); + logging.AddFilter("Microsoft.AspNetCore.DataProtection", LogLevel.None); + logging.AddFilter("Microsoft.Hosting.Lifetime", LogLevel.None); + logging.AddFilter("Yarp.ReverseProxy", LogLevel.Warning); + + // Re-add logging configuration so user overrides in the Logging section + // take precedence over our defaults above + logging.AddConfiguration(configuration.GetSection("Logging")); + + return logging; + } + + /// + /// Print startup banner showing what's configured. + /// Written directly to Console so it always shows regardless of log level. + /// + public static void PrintBanner(YarpAppConfig config, string? configFilePath, WebApplication app) + { + Console.WriteLine(); + Console.WriteLine("YARP"); + Console.WriteLine(); + + if (configFilePath is not null) + { + Console.WriteLine($" Config: {configFilePath}"); + } + + if (config.StaticFiles.Enabled) + { + var webRoot = app.Environment.WebRootPath ?? "(not found)"; + Console.WriteLine($" Static files: {webRoot}"); + } + + if (config.NavigationFallback.Path is not null) + { + Console.WriteLine($" SPA fallback: {config.NavigationFallback.Path}"); + } + + Console.WriteLine(); + + // Print listening URLs once the server has started + app.Lifetime.ApplicationStarted.Register(() => + { + var addresses = app.Services.GetRequiredService() + .Features.Get()?.Addresses; + + if (addresses is { Count: > 0 }) + { + foreach (var address in addresses) + { + Console.WriteLine($" Listening on: {address}"); + } + Console.WriteLine(); + } + }); + } +} diff --git a/src/Application/Features/NavigationFallbackFeature.cs b/src/Application/Features/NavigationFallbackFeature.cs new file mode 100644 index 000000000..9ba98a9ac --- /dev/null +++ b/src/Application/Features/NavigationFallbackFeature.cs @@ -0,0 +1,20 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Builder; +using Yarp.Application.Configuration; + +namespace Yarp.Application.Features; + +public static class NavigationFallbackFeature +{ + public static WebApplication MapNavigationFallback(this WebApplication app, YarpAppConfig config) + { + if (config.NavigationFallback.Path is not null) + { + app.MapFallbackToFile(config.NavigationFallback.Path); + } + + return app; + } +} diff --git a/src/Application/Features/ReverseProxyFeature.cs b/src/Application/Features/ReverseProxyFeature.cs new file mode 100644 index 000000000..2ae5b7d47 --- /dev/null +++ b/src/Application/Features/ReverseProxyFeature.cs @@ -0,0 +1,22 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Yarp.Application.Features; + +public static class ReverseProxyFeature +{ + public static IHostApplicationBuilder AddReverseProxy(this IHostApplicationBuilder builder) + { + builder.Services.AddServiceDiscovery(); + builder.Services.AddReverseProxy() + .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy")) + .AddServiceDiscoveryDestinationResolver(); + + return builder; + } +} diff --git a/src/Application/Features/StaticFilesFeature.cs b/src/Application/Features/StaticFilesFeature.cs new file mode 100644 index 000000000..32dca2a31 --- /dev/null +++ b/src/Application/Features/StaticFilesFeature.cs @@ -0,0 +1,20 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Builder; +using Yarp.Application.Configuration; + +namespace Yarp.Application.Features; + +public static class StaticFilesFeature +{ + public static WebApplication UseStaticFiles(this WebApplication app, YarpAppConfig config) + { + if (config.StaticFiles.Enabled) + { + app.UseFileServer(); + } + + return app; + } +} diff --git a/src/Application/Program.cs b/src/Application/Program.cs index 2a3e091f0..ead454944 100644 --- a/src/Application/Program.cs +++ b/src/Application/Program.cs @@ -2,51 +2,74 @@ // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Yarp.Application.Configuration; +using Yarp.Application.Features; -var builder = WebApplication.CreateBuilder(); - -// Load configuration from file if passed +// Parse config file path from args before creating the builder +// so we can set ContentRoot/WebRoot via WebApplicationOptions +string? configFilePath = null; if (args.Length == 1) { - var configFile = args[0]; - var fileInfo = new FileInfo(configFile); + var fileInfo = new FileInfo(args[0]); if (!fileInfo.Exists) { - Console.Error.WriteLine($"Could not find '{configFile}'."); + Console.Error.WriteLine($"Could not find '{args[0]}'."); return 2; } - builder.Configuration.AddJsonFile(fileInfo.FullName, optional: false, reloadOnChange: true); - builder.Configuration.AddEnvironmentVariables(); + configFilePath = fileInfo.FullName; } -// Configure YARP -builder.AddServiceDefaults(); -builder.Services.AddReverseProxy() - .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy")) - .AddServiceDiscoveryDestinationResolver(); - -var app = builder.Build(); -var enableStaticFiles = string.Equals(app.Configuration["YARP_ENABLE_STATIC_FILES"], "true", StringComparison.OrdinalIgnoreCase); -if (enableStaticFiles) +WebApplicationOptions options; +if (configFilePath is not null) { - app.UseFileServer(); + var configDir = Path.GetDirectoryName(configFilePath) ?? Directory.GetCurrentDirectory(); + options = new WebApplicationOptions + { + ContentRootPath = configDir, + WebRootPath = Path.Combine(configDir, "wwwroot") + }; +} +else +{ + options = new WebApplicationOptions(); } -app.UseRouting(); -app.MapReverseProxy(); -if (enableStaticFiles) +var builder = WebApplication.CreateBuilder(options); + +// Suppress noisy framework logs on console by default +builder.Logging.ConfigureDefaultLogging(builder.Configuration); + +// Load configuration from file if passed +if (configFilePath is not null) { - var disableSpaFallback = string.Equals(app.Configuration["YARP_DISABLE_SPA_FALLBACK"], "true", StringComparison.OrdinalIgnoreCase); - if (!disableSpaFallback) - { - app.MapFallbackToFile("index.html"); - } + builder.Configuration.AddJsonFile(configFilePath, optional: false, reloadOnChange: true); + builder.Configuration.AddEnvironmentVariables(); } +// Test hook: allows WebApplicationFactory to inject config before binding +// See: https://github.com/dotnet/aspnetcore/issues/37680 +builder.Configuration.AddTestConfiguration(); + +// Bind config into the object model — single conversion point, before Build() +var config = YarpAppConfigBinder.Bind(builder.Configuration); + +// Services +builder.AddServiceDefaults(config); +builder.AddReverseProxy(); + +var app = builder.Build(); + +// Print startup banner before any middleware runs +LoggingFeature.PrintBanner(config, configFilePath, app); + +// Middleware pipeline — order matters +app.UseStaticFiles(config); +app.UseRouting(); +app.MapReverseProxy(); +app.MapNavigationFallback(config); + await app.RunAsync(); return 0; diff --git a/src/Application/README.md b/src/Application/README.md new file mode 100644 index 000000000..a597ca668 --- /dev/null +++ b/src/Application/README.md @@ -0,0 +1,164 @@ +# YARP Container Application + +An opinionated web server and reverse proxy built on ASP.NET Core and [YARP](https://dotnet.github.io/yarp/). JSON config, no code required. + +## Quick Start + +Create a `yarp-config.json` next to your `wwwroot/` directory: + +```json +{ + "$schema": "./yarp-config.schema.json", + + "StaticFiles": { + "Enabled": true + }, + "NavigationFallback": { + "Path": "/index.html" + }, + "ReverseProxy": { + "Routes": { + "api": { + "ClusterId": "backend", + "Match": { "Path": "/api/{**catch-all}" } + } + }, + "Clusters": { + "backend": { + "Destinations": { + "d1": { "Address": "http://backend:5000" } + } + } + } + } +} +``` + +Run it: + +```bash +yarp ./yarp-config.json +``` + +```text +YARP + + Config: ./yarp-config.json + Static files: ./wwwroot + SPA fallback: /index.html + + Listening on: http://localhost:5000 +``` + +## Container Usage + +```yaml +services: + yarp: + image: yarp + environment: + - StaticFiles__Enabled=true + - NavigationFallback__Path=/index.html + volumes: + - ./yarp-config.json:/etc/yarp-config.json + - ./wwwroot:/etc/wwwroot + command: ["/etc/yarp-config.json"] + ports: + - "5000:5000" +``` + +Mount the config file and `wwwroot/` into the same directory. The app uses the config file directory as the content root and serves static files from its `wwwroot/` subdirectory. + +Simple toggles work as environment variables. Complex config (proxy routes, etc.) goes in the JSON file. + +## Configuration + +All configuration goes through `IConfiguration` — JSON files, environment variables, or any other provider. See [`yarp-config.schema.json`](yarp-config.schema.json) for IDE autocomplete and validation. + +### `StaticFiles` + +Serve static files from `wwwroot/`. + +```json +{ "StaticFiles": { "Enabled": true } } +``` + +### `NavigationFallback` + +SPA fallback — serve a file (typically `index.html`) for unmatched routes so client-side routing works. + +```json +{ "NavigationFallback": { "Path": "/index.html" } } +``` + +### `ReverseProxy` + +YARP reverse proxy routes and clusters. See the [YARP configuration docs](https://learn.microsoft.com/aspnet/core/fundamentals/servers/yarp/config-files) for the full reference. + +### `Telemetry` + +OTLP export uses standard `OTEL_*` environment variables. This section covers YARP-specific telemetry options. + +```json +{ "Telemetry": { "UnsafeAcceptAnyCertificate": true } } +``` + +## Logging + +By default, the console shows a clean startup banner and warnings/errors only — no per-request noise. Framework logs (DataProtection, Hosting.Lifetime, etc.) are suppressed on console but still flow to other providers (OTEL). + +To re-enable framework logs for debugging, use the standard `Logging` config: + +```json +{ + "Logging": { + "Console": { + "LogLevel": { + "Microsoft.AspNetCore": "Information" + } + } + } +} +``` + +## Architecture + +This is an opinionated, pre-built application — not an extensible framework. Users who need custom behavior should use the [YARP library](https://dotnet.github.io/yarp/) directly in their own ASP.NET Core app. + +### Project Structure + +```text +Configuration/ Config model (IConfiguration → POCOs) + YarpAppConfig.cs Root config object + YarpAppConfigBinder.cs Single conversion point + legacy key mapping + StaticFilesOptions.cs Per-feature options + NavigationFallbackOptions.cs + TelemetryOptions.cs +Features/ Per-feature extension methods + StaticFilesFeature.cs + NavigationFallbackFeature.cs + ReverseProxyFeature.cs + LoggingFeature.cs +Program.cs Pipeline ordering +Extensions.cs Service defaults (telemetry, health checks) +yarp-config.schema.json JSON Schema for IDE support +``` + +### Adding a Feature + +1. Add options class: `Configuration/XxxOptions.cs` +2. Add property to `YarpAppConfig.cs` +3. Add bind line to `YarpAppConfigBinder.cs` +4. Add feature logic: `Features/XxxFeature.cs` +5. Add call to `Program.cs` in the correct pipeline position +6. Add section to `yarp-config.schema.json` + +## Legacy Environment Variables + +These continue to work for backward compatibility: + +| Legacy Key | Maps To | +| --- | --- | +| `YARP_ENABLE_STATIC_FILES` | `StaticFiles:Enabled` | +| `YARP_DISABLE_SPA_FALLBACK` | Disables `NavigationFallback:Path` | +| `YARP_UNSAFE_OLTP_CERT_ACCEPT_ANY_SERVER_CERTIFICATE` | `Telemetry:UnsafeAcceptAnyCertificate` | diff --git a/src/Application/Yarp.Application.csproj b/src/Application/Yarp.Application.csproj index 859d9fb29..35048b1d8 100644 --- a/src/Application/Yarp.Application.csproj +++ b/src/Application/Yarp.Application.csproj @@ -14,6 +14,10 @@ + + + + diff --git a/src/Application/yarp-config.schema.json b/src/Application/yarp-config.schema.json new file mode 100644 index 000000000..35a561486 --- /dev/null +++ b/src/Application/yarp-config.schema.json @@ -0,0 +1,48 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "YARP Container Configuration", + "description": "Configuration schema for the YARP container application", + "type": "object", + "properties": { + "StaticFiles": { + "type": "object", + "description": "Static file serving configuration", + "properties": { + "Enabled": { + "type": "boolean", + "default": false, + "description": "Enable static file serving from wwwroot" + } + }, + "additionalProperties": false + }, + "NavigationFallback": { + "type": "object", + "description": "SPA navigation fallback configuration", + "properties": { + "Path": { + "type": "string", + "description": "File to serve for unmatched non-file routes (e.g., /index.html)" + } + }, + "additionalProperties": false + }, + "Telemetry": { + "type": "object", + "description": "Telemetry configuration. OTLP export uses standard OTEL_* env vars.", + "properties": { + "UnsafeAcceptAnyCertificate": { + "type": "boolean", + "default": false, + "description": "Skip TLS validation for the OTLP exporter (dev/test only)" + } + }, + "additionalProperties": false + }, + "ReverseProxy": { + "type": "object", + "description": "YARP reverse proxy configuration. See https://microsoft.github.io/reverse-proxy/articles/config-files.html" + } + }, + "additionalProperties": true +} diff --git a/test/Application.Tests/SpaFallbackTests.cs b/test/Application.Tests/SpaFallbackTests.cs index d3294aab1..9d432a813 100644 --- a/test/Application.Tests/SpaFallbackTests.cs +++ b/test/Application.Tests/SpaFallbackTests.cs @@ -2,32 +2,72 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Net; +using System.Text.Json; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.Configuration; using Xunit; +using Yarp.Application.Configuration; namespace Yarp.Application.Tests; -public class SpaFallbackTests +internal class YarpTestApp : WebApplicationFactory { - private static WebApplicationFactory CreateFactory(Dictionary? config = null) + private Action? _configAction; + + public void ConfigureConfiguration(Action configure) + { + _configAction += configure; + } + + public void Configure(YarpAppConfig config) + { + ConfigureConfiguration(b => + { + var json = JsonSerializer.Serialize(config); + using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); + b.AddJsonStream(stream); + }); + } + + public void Configure(Dictionary config) + { + ConfigureConfiguration(b => b.AddInMemoryCollection(config)); + } + + protected override IWebHostBuilder? CreateWebHostBuilder() + { + if (_configAction is { } a) + { + TestConfiguration.Create(a); + } + + return base.CreateWebHostBuilder(); + } + + protected override void ConfigureWebHost(IWebHostBuilder builder) { - var factory = new WebApplicationFactory() - .WithWebHostBuilder(builder => - { - builder.UseWebRoot(Path.Combine(AppContext.BaseDirectory, "wwwroot")); + builder.UseWebRoot(Path.Combine(AppContext.BaseDirectory, "wwwroot")); + } +} - if (config is not null) - { - builder.ConfigureAppConfiguration((context, configBuilder) => - { - configBuilder.AddInMemoryCollection(config); - }); - } - }); +public class SpaFallbackTests +{ + private static YarpTestApp CreateApp(YarpAppConfig config) + { + var app = new YarpTestApp(); + app.Configure(config); + return app; + } - return factory; + private static YarpTestApp CreateApp(Dictionary? config = null) + { + var app = new YarpTestApp(); + if (config is not null) + { + app.Configure(config); + } + return app; } private static Dictionary StaticFilesEnabled() => new() @@ -38,7 +78,7 @@ private static WebApplicationFactory CreateFactory(Dictionary() { ["YARP_ENABLE_STATIC_FILES"] = "true", ["YARP_DISABLE_SPA_FALLBACK"] = "true" @@ -120,7 +160,7 @@ public async Task SpaFallback_Disabled_Returns404_ForUnknownRoutes() [Fact] public async Task SpaFallback_Disabled_StillServesStaticFiles() { - using var factory = CreateFactory(new() + using var factory = CreateApp(new Dictionary() { ["YARP_ENABLE_STATIC_FILES"] = "true", ["YARP_DISABLE_SPA_FALLBACK"] = "true" @@ -137,7 +177,7 @@ public async Task SpaFallback_Disabled_StillServesStaticFiles() [Fact] public async Task StaticFiles_Disabled_Returns404_ForAll() { - using var factory = CreateFactory(); + using var factory = CreateApp(); using var client = factory.CreateClient(); var indexResponse = await client.GetAsync("/index.html"); @@ -154,7 +194,7 @@ public async Task SpaFallback_CoexistsWithSpecificYarpRoutes() { // When YARP has specific routes (e.g. /api/), non-YARP paths // should still fall back to index.html for SPA routing - using var factory = CreateFactory(new() + using var factory = CreateApp(new Dictionary() { ["YARP_ENABLE_STATIC_FILES"] = "true", ["ReverseProxy:Routes:api:ClusterId"] = "backend", @@ -179,7 +219,7 @@ public async Task SpaFallback_CatchAllYarpRoute_WinsOverFallback() { // When YARP has a catch-all route, it takes priority over the // SPA fallback — this is expected because YARP owns all routing - using var factory = CreateFactory(new() + using var factory = CreateApp(new Dictionary() { ["YARP_ENABLE_STATIC_FILES"] = "true", ["ReverseProxy:Routes:catchall:ClusterId"] = "backend", @@ -193,4 +233,70 @@ public async Task SpaFallback_CatchAllYarpRoute_WinsOverFallback() var response = await client.GetAsync("/some/spa/route"); Assert.NotEqual(HttpStatusCode.OK, response.StatusCode); } + + // Tests using the strongly-typed config object model + + [Fact] + public async Task ObjectModel_StaticFilesAndFallback() + { + using var app = CreateApp(new YarpAppConfig + { + StaticFiles = { Enabled = true }, + NavigationFallback = { Path = "/index.html" } + }); + using var client = app.CreateClient(); + + var cssResponse = await client.GetAsync("/style.css"); + Assert.Equal(HttpStatusCode.OK, cssResponse.StatusCode); + + var spaResponse = await client.GetAsync("/some/spa/route"); + Assert.Equal(HttpStatusCode.OK, spaResponse.StatusCode); + var content = await spaResponse.Content.ReadAsStringAsync(); + Assert.Contains("SPA Index", content); + } + + [Fact] + public async Task ObjectModel_StaticFilesWithoutFallback() + { + using var app = CreateApp(new YarpAppConfig + { + StaticFiles = { Enabled = true } + }); + using var client = app.CreateClient(); + + var cssResponse = await client.GetAsync("/style.css"); + Assert.Equal(HttpStatusCode.OK, cssResponse.StatusCode); + + var spaResponse = await client.GetAsync("/some/spa/route"); + Assert.Equal(HttpStatusCode.NotFound, spaResponse.StatusCode); + } + + [Fact] + public async Task ObjectModel_CustomFallbackPath() + { + using var app = CreateApp(new YarpAppConfig + { + StaticFiles = { Enabled = true }, + NavigationFallback = { Path = "/index.html" } + }); + using var client = app.CreateClient(); + + var response = await client.GetAsync("/deep/nested/route"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + Assert.Contains("SPA Index", content); + } + + [Fact] + public async Task ObjectModel_EverythingDisabled() + { + using var app = CreateApp(new YarpAppConfig()); + using var client = app.CreateClient(); + + var indexResponse = await client.GetAsync("/index.html"); + var cssResponse = await client.GetAsync("/style.css"); + + Assert.Equal(HttpStatusCode.NotFound, indexResponse.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, cssResponse.StatusCode); + } } diff --git a/test/Application.Tests/YarpAppConfigBinderTests.cs b/test/Application.Tests/YarpAppConfigBinderTests.cs new file mode 100644 index 000000000..c798fa801 --- /dev/null +++ b/test/Application.Tests/YarpAppConfigBinderTests.cs @@ -0,0 +1,139 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Configuration; +using Xunit; +using Yarp.Application.Configuration; + +namespace Yarp.Application.Tests; + +public class YarpAppConfigBinderTests +{ + private static YarpAppConfig Bind(Dictionary config) + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(config) + .Build(); + return YarpAppConfigBinder.Bind(configuration); + } + + // New config format + + [Fact] + public void Bind_StaticFilesEnabled() + { + var config = Bind(new() { ["StaticFiles:Enabled"] = "true" }); + Assert.True(config.StaticFiles.Enabled); + } + + [Fact] + public void Bind_NavigationFallbackPath() + { + var config = Bind(new() { ["NavigationFallback:Path"] = "/app.html" }); + Assert.Equal("/app.html", config.NavigationFallback.Path); + } + + [Fact] + public void Bind_TelemetryUnsafeCert() + { + var config = Bind(new() { ["Telemetry:UnsafeAcceptAnyCertificate"] = "true" }); + Assert.True(config.Telemetry.UnsafeAcceptAnyCertificate); + } + + [Fact] + public void Bind_Defaults_EverythingOff() + { + var config = Bind(new()); + Assert.False(config.StaticFiles.Enabled); + Assert.Null(config.NavigationFallback.Path); + Assert.False(config.Telemetry.UnsafeAcceptAnyCertificate); + } + + // Legacy key mapping + + [Fact] + public void Legacy_EnableStaticFiles() + { + var config = Bind(new() { ["YARP_ENABLE_STATIC_FILES"] = "true" }); + Assert.True(config.StaticFiles.Enabled); + } + + [Fact] + public void Legacy_EnableStaticFiles_ImpliesFallback() + { + var config = Bind(new() { ["YARP_ENABLE_STATIC_FILES"] = "true" }); + Assert.Equal("/index.html", config.NavigationFallback.Path); + } + + [Fact] + public void Legacy_DisableSpaFallback() + { + var config = Bind(new() + { + ["YARP_ENABLE_STATIC_FILES"] = "true", + ["YARP_DISABLE_SPA_FALLBACK"] = "true" + }); + Assert.True(config.StaticFiles.Enabled); + Assert.Null(config.NavigationFallback.Path); + } + + [Fact] + public void Legacy_UnsafeCert() + { + var config = Bind(new() { ["YARP_UNSAFE_OLTP_CERT_ACCEPT_ANY_SERVER_CERTIFICATE"] = "true" }); + Assert.True(config.Telemetry.UnsafeAcceptAnyCertificate); + } + + // Precedence: new config wins over legacy + + [Fact] + public void Precedence_NewConfigSection_PreventsLegacyFallback() + { + // When NavigationFallback section exists explicitly, legacy + // YARP_ENABLE_STATIC_FILES doesn't auto-set fallback path + var config = Bind(new() + { + ["YARP_ENABLE_STATIC_FILES"] = "true", + ["NavigationFallback:Path"] = "/custom.html" + }); + Assert.Equal("/custom.html", config.NavigationFallback.Path); + } + + [Fact] + public void Precedence_ExplicitFallbackWinsOverLegacy() + { + var config = Bind(new() + { + ["YARP_ENABLE_STATIC_FILES"] = "true", + ["NavigationFallback:Path"] = "/custom.html" + }); + // Explicit NavigationFallback takes precedence over legacy default + Assert.Equal("/custom.html", config.NavigationFallback.Path); + } + + [Fact] + public void Precedence_NewTelemetryWinsOverLegacy() + { + var config = Bind(new() + { + ["Telemetry:UnsafeAcceptAnyCertificate"] = "true", + ["YARP_UNSAFE_OLTP_CERT_ACCEPT_ANY_SERVER_CERTIFICATE"] = "false" + }); + Assert.True(config.Telemetry.UnsafeAcceptAnyCertificate); + } + + [Fact] + public void Legacy_StaticFilesDisabled_NoFallback() + { + // Without YARP_ENABLE_STATIC_FILES, no fallback should be set + var config = Bind(new()); + Assert.Null(config.NavigationFallback.Path); + } + + [Fact] + public void Legacy_CaseInsensitive() + { + var config = Bind(new() { ["YARP_ENABLE_STATIC_FILES"] = "True" }); + Assert.True(config.StaticFiles.Enabled); + } +}