From 03f77771cca2d9833c9961dcc1b49b1f728280ca Mon Sep 17 00:00:00 2001 From: Warwick Schroeder Date: Mon, 24 Nov 2025 12:03:22 +0800 Subject: [PATCH 01/24] Add initial authentication supporting JWT tokens, OpenID Connect, OAuth2.0 --- src/Directory.Packages.props | 4 + src/ServiceControl/App.config | 13 ++ .../Hosting/Commands/RunCommand.cs | 45 ++++++- .../Settings/OpenIdConnectSettings.cs | 125 ++++++++++++++++++ .../Infrastructure/Settings/Settings.cs | 4 + .../Infrastructure/WebApi/Cors.cs | 2 +- src/ServiceControl/ServiceControl.csproj | 4 + .../WebApplicationExtensions.cs | 17 ++- 8 files changed, 210 insertions(+), 4 deletions(-) create mode 100644 src/ServiceControl/Infrastructure/Settings/OpenIdConnectSettings.cs diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index ddb7d493c5..5da0825705 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -17,6 +17,8 @@ + + @@ -28,6 +30,8 @@ + + diff --git a/src/ServiceControl/App.config b/src/ServiceControl/App.config index 953b727cc8..bb8b9a99e5 100644 --- a/src/ServiceControl/App.config +++ b/src/ServiceControl/App.config @@ -27,6 +27,19 @@ These settings are only here so that we can debug ServiceControl while developin + + + + + + + diff --git a/src/ServiceControl/Hosting/Commands/RunCommand.cs b/src/ServiceControl/Hosting/Commands/RunCommand.cs index db658857de..5df4ceb8df 100644 --- a/src/ServiceControl/Hosting/Commands/RunCommand.cs +++ b/src/ServiceControl/Hosting/Commands/RunCommand.cs @@ -1,8 +1,13 @@ namespace ServiceControl.Hosting.Commands { + using System; using System.Threading.Tasks; using Infrastructure.WebApi; + using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Builder; + using Microsoft.Extensions.DependencyInjection; + using Microsoft.IdentityModel.JsonWebTokens; + using Microsoft.IdentityModel.Tokens; using NServiceBus; using Particular.ServiceControl; using Particular.ServiceControl.Hosting; @@ -20,11 +25,49 @@ public override async Task Execute(HostArguments args, Settings settings) settings.RunCleanupBundle = true; var hostBuilder = WebApplication.CreateBuilder(); + + // Configure JWT Bearer Authentication with OpenID Connect + if (settings.OpenIdConnectSettings.Enabled) + { + hostBuilder.Services.AddAuthentication(options => + { + options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; + }) + .AddJwtBearer(options => + { + var oidcSettings = settings.OpenIdConnectSettings; + + options.Authority = oidcSettings.Authority; + + // Configure token validation parameters + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = oidcSettings.ValidateIssuer, + ValidateAudience = oidcSettings.ValidateAudience, + ValidateLifetime = oidcSettings.ValidateLifetime, + ValidateIssuerSigningKey = oidcSettings.ValidateIssuerSigningKey, + ValidAudience = oidcSettings.Audience, + ClockSkew = TimeSpan.FromMinutes(5) // Allow 5 minutes clock skew + }; + + options.RequireHttpsMetadata = oidcSettings.RequireHttpsMetadata; + + // Don't map inbound claims to legacy Microsoft claim types + options.MapInboundClaims = false; + }); + + // Clear the default claim type mappings to use standard JWT claim names + JsonWebTokenHandler.DefaultInboundClaimTypeMap.Clear(); + } + + hostBuilder.AddServiceControl(settings, endpointConfiguration); hostBuilder.AddServiceControlApi(); var app = hostBuilder.Build(); - app.UseServiceControl(); + app.UseServiceControl(authenticationEnabled: settings.OpenIdConnectSettings.Enabled); + await app.RunAsync(settings.RootUrl); } } diff --git a/src/ServiceControl/Infrastructure/Settings/OpenIdConnectSettings.cs b/src/ServiceControl/Infrastructure/Settings/OpenIdConnectSettings.cs new file mode 100644 index 0000000000..a3eec17994 --- /dev/null +++ b/src/ServiceControl/Infrastructure/Settings/OpenIdConnectSettings.cs @@ -0,0 +1,125 @@ +namespace ServiceBus.Management.Infrastructure.Settings +{ + using System; + using System.Text.Json.Serialization; + using Microsoft.Extensions.Logging; + using ServiceControl.Configuration; + using ServiceControl.Infrastructure; + + public class OpenIdConnectSettings + { + readonly ILogger logger = LoggerUtil.CreateStaticLogger(); + + public OpenIdConnectSettings(bool validateConfiguration) + { + Enabled = SettingsReader.Read(Settings.SettingsRootNamespace, "Authentication.Enabled", false); + + if (!Enabled) + { + return; + } + + Authority = SettingsReader.Read(Settings.SettingsRootNamespace, "Authentication.Authority"); + Audience = SettingsReader.Read(Settings.SettingsRootNamespace, "Authentication.Audience"); + ValidateIssuer = SettingsReader.Read(Settings.SettingsRootNamespace, "Authentication.ValidateIssuer", true); + ValidateAudience = SettingsReader.Read(Settings.SettingsRootNamespace, "Authentication.ValidateAudience", true); + ValidateLifetime = SettingsReader.Read(Settings.SettingsRootNamespace, "Authentication.ValidateLifetime", true); + ValidateIssuerSigningKey = SettingsReader.Read(Settings.SettingsRootNamespace, "Authentication.ValidateIssuerSigningKey", true); + RequireHttpsMetadata = SettingsReader.Read(Settings.SettingsRootNamespace, "Authentication.RequireHttpsMetadata", true); + + if (validateConfiguration) + { + Validate(); + } + } + + [JsonPropertyName("enabled")] + public bool Enabled { get; } + + [JsonPropertyName("authority")] + public string Authority { get; } + + [JsonPropertyName("audience")] + public string Audience { get; } + + [JsonPropertyName("validateIssuer")] + public bool ValidateIssuer { get; } + + [JsonPropertyName("validateAudience")] + public bool ValidateAudience { get; } + + [JsonPropertyName("validateLifetime")] + public bool ValidateLifetime { get; } + + [JsonPropertyName("validateIssuerSigningKey")] + public bool ValidateIssuerSigningKey { get; } + + [JsonPropertyName("requireHttpsMetadata")] + public bool RequireHttpsMetadata { get; } + + void Validate() + { + if (!Enabled) + { + return; + } + + if (string.IsNullOrWhiteSpace(Authority)) + { + var message = "Authentication.Authority is required when authentication is enabled. Please provide a valid OpenID Connect authority URL (e.g., https://login.microsoftonline.com/{tenant-id}/v2.0)"; + logger.LogCritical(message); + throw new Exception(message); + } + + if (!Uri.TryCreate(Authority, UriKind.Absolute, out var authorityUri)) + { + var message = $"Authentication.Authority must be a valid absolute URI. Current value: '{Authority}'"; + logger.LogCritical(message); + throw new Exception(message); + } + + if (RequireHttpsMetadata && authorityUri.Scheme != Uri.UriSchemeHttps) + { + var message = $"Authentication.Authority must use HTTPS when RequireHttpsMetadata is true. Current value: '{Authority}'. Either use HTTPS or set Authentication.RequireHttpsMetadata to false (not recommended for production)"; + logger.LogCritical(message); + throw new Exception(message); + } + + if (string.IsNullOrWhiteSpace(Audience)) + { + var message = "Authentication.Audience is required when authentication is enabled. Please provide a valid audience identifier (typically your API identifier or client ID)"; + logger.LogCritical(message); + throw new Exception(message); + } + + if (!ValidateIssuer) + { + logger.LogWarning("Authentication.ValidateIssuer is set to false. This is not recommended for production environments as it may allow tokens from untrusted issuers"); + } + + if (!ValidateAudience) + { + logger.LogWarning("Authentication.ValidateAudience is set to false. This is not recommended for production environments as it may allow tokens intended for other applications"); + } + + if (!ValidateLifetime) + { + logger.LogWarning("Authentication.ValidateLifetime is set to false. This is not recommended as it may allow expired tokens to be accepted"); + } + + if (!ValidateIssuerSigningKey) + { + logger.LogWarning("Authentication.ValidateIssuerSigningKey is set to false. This is a serious security risk and should only be used in development environments"); + } + + logger.LogInformation("Authentication configuration validated successfully"); + logger.LogInformation(" Authority: {Authority}", Authority); + logger.LogInformation(" Audience: {Audience}", Audience); + logger.LogInformation(" ValidateIssuer: {ValidateIssuer}", ValidateIssuer); + logger.LogInformation(" ValidateAudience: {ValidateAudience}", ValidateAudience); + logger.LogInformation(" ValidateLifetime: {ValidateLifetime}", ValidateLifetime); + logger.LogInformation(" ValidateIssuerSigningKey: {ValidateIssuerSigningKey}", ValidateIssuerSigningKey); + logger.LogInformation(" RequireHttpsMetadata: {RequireHttpsMetadata}", RequireHttpsMetadata); + } + } +} diff --git a/src/ServiceControl/Infrastructure/Settings/Settings.cs b/src/ServiceControl/Infrastructure/Settings/Settings.cs index 772d33203d..714f0ee6e3 100644 --- a/src/ServiceControl/Infrastructure/Settings/Settings.cs +++ b/src/ServiceControl/Infrastructure/Settings/Settings.cs @@ -37,6 +37,8 @@ public Settings( LoadErrorIngestionSettings(); + OpenIdConnectSettings = new OpenIdConnectSettings(ValidateConfiguration); + TransportConnectionString = GetConnectionString(); TransportType = transportType ?? SettingsReader.Read(SettingsRootNamespace, "TransportType"); PersistenceType = persisterType ?? SettingsReader.Read(SettingsRootNamespace, "PersistenceType"); @@ -181,6 +183,8 @@ public TimeSpan HeartbeatGracePeriod public bool DisableHealthChecks { get; set; } + public OpenIdConnectSettings OpenIdConnectSettings { get; } + // The default value is set to the maximum allowed time by the most // restrictive hosting platform, which is Linux containers. Linux // containers allow for a maximum of 10 seconds. We set it to 5 to diff --git a/src/ServiceControl/Infrastructure/WebApi/Cors.cs b/src/ServiceControl/Infrastructure/WebApi/Cors.cs index ce8e8eb7cf..14abc35bbc 100644 --- a/src/ServiceControl/Infrastructure/WebApi/Cors.cs +++ b/src/ServiceControl/Infrastructure/WebApi/Cors.cs @@ -10,7 +10,7 @@ public static CorsPolicy GetDefaultPolicy() builder.AllowAnyOrigin(); builder.WithExposedHeaders(["ETag", "Last-Modified", "Link", "Total-Count", "X-Particular-Version", "Content-Disposition"]); - builder.WithHeaders(["Origin", "X-Requested-With", "Content-Type", "Accept"]); + builder.WithHeaders(["Origin", "X-Requested-With", "Content-Type", "Accept", "Authorization"]); builder.WithMethods(["POST", "GET", "PUT", "DELETE", "OPTIONS", "PATCH", "HEAD"]); return builder.Build(); diff --git a/src/ServiceControl/ServiceControl.csproj b/src/ServiceControl/ServiceControl.csproj index 04f5956ccf..41f3c49bda 100644 --- a/src/ServiceControl/ServiceControl.csproj +++ b/src/ServiceControl/ServiceControl.csproj @@ -28,7 +28,11 @@ + + + + diff --git a/src/ServiceControl/WebApplicationExtensions.cs b/src/ServiceControl/WebApplicationExtensions.cs index dfa7511613..5d3229eab6 100644 --- a/src/ServiceControl/WebApplicationExtensions.cs +++ b/src/ServiceControl/WebApplicationExtensions.cs @@ -7,7 +7,7 @@ namespace ServiceControl; public static class WebApplicationExtensions { - public static void UseServiceControl(this WebApplication app) + public static void UseServiceControl(this WebApplication app, bool authenticationEnabled = false) { app.UseForwardedHeaders(new ForwardedHeadersOptions { ForwardedHeaders = ForwardedHeaders.All }); app.UseResponseCompression(); @@ -15,6 +15,19 @@ public static void UseServiceControl(this WebApplication app) app.UseHttpLogging(); app.MapHub("/api/messagestream"); app.UseCors(); - app.MapControllers(); + + // Always add middleware (harmless when not configured) + app.UseAuthentication(); + app.UseAuthorization(); + + // Only require authorization if authentication is enabled + if (authenticationEnabled) + { + app.MapControllers().RequireAuthorization(); + } + else + { + app.MapControllers(); + } } } \ No newline at end of file From f8e77ffeebd39fa4079054e1add4e45a4a5d357a Mon Sep 17 00:00:00 2001 From: Jason Taylor Date: Wed, 26 Nov 2025 10:30:44 +1000 Subject: [PATCH 02/24] Add ServicePulse-specific OIDC configuration and endpoint --- src/ServiceControl/App.config | 11 +++-- .../AuthenticationController.cs | 35 +++++++++++++++ .../Settings/OpenIdConnectSettings.cs | 43 +++++++++++++++++++ 3 files changed, 86 insertions(+), 3 deletions(-) create mode 100644 src/ServiceControl/Authentication/AuthenticationController.cs diff --git a/src/ServiceControl/App.config b/src/ServiceControl/App.config index bb8b9a99e5..67274723e1 100644 --- a/src/ServiceControl/App.config +++ b/src/ServiceControl/App.config @@ -31,15 +31,20 @@ These settings are only here so that we can debug ServiceControl while developin - + + + + + + + + diff --git a/src/ServiceControl/Authentication/AuthenticationController.cs b/src/ServiceControl/Authentication/AuthenticationController.cs new file mode 100644 index 0000000000..64829d0927 --- /dev/null +++ b/src/ServiceControl/Authentication/AuthenticationController.cs @@ -0,0 +1,35 @@ +namespace ServiceControl.Authentication +{ + using Microsoft.AspNetCore.Authorization; + using Microsoft.AspNetCore.Mvc; + using ServiceBus.Management.Infrastructure.Settings; + + [ApiController] + [Route("api/authentication")] + public class AuthenticationController(Settings settings) : ControllerBase + { + [HttpGet] + [AllowAnonymous] + [Route("configuration")] + public ActionResult Configuration() + { + var info = new AuthConfig + { + Enabled = settings.OpenIdConnectSettings.ServicePulseEnabled, + ClientId = settings.OpenIdConnectSettings.ServicePulseClientId, + Authority = settings.OpenIdConnectSettings.ServicePulseAuthority, + ApiScope = settings.OpenIdConnectSettings.ServicePulseApiScope + }; + + return Ok(info); + } + } + + public class AuthConfig + { + public bool Enabled { get; set; } + public string ClientId { get; set; } + public string Authority { get; set; } + public string ApiScope { get; set; } + } +} diff --git a/src/ServiceControl/Infrastructure/Settings/OpenIdConnectSettings.cs b/src/ServiceControl/Infrastructure/Settings/OpenIdConnectSettings.cs index a3eec17994..63707ae7aa 100644 --- a/src/ServiceControl/Infrastructure/Settings/OpenIdConnectSettings.cs +++ b/src/ServiceControl/Infrastructure/Settings/OpenIdConnectSettings.cs @@ -27,6 +27,15 @@ public OpenIdConnectSettings(bool validateConfiguration) ValidateIssuerSigningKey = SettingsReader.Read(Settings.SettingsRootNamespace, "Authentication.ValidateIssuerSigningKey", true); RequireHttpsMetadata = SettingsReader.Read(Settings.SettingsRootNamespace, "Authentication.RequireHttpsMetadata", true); + ServicePulseEnabled = SettingsReader.Read(Settings.SettingsRootNamespace, "Authentication.ServicePulse.Enabled", false); + + if (ServicePulseEnabled) + { + ServicePulseClientId = SettingsReader.Read(Settings.SettingsRootNamespace, "Authentication.ServicePulse.ClientId"); + ServicePulseApiScope = SettingsReader.Read(Settings.SettingsRootNamespace, "Authentication.ServicePulse.ApiScope"); + ServicePulseAuthority = SettingsReader.Read(Settings.SettingsRootNamespace, "Authentication.ServicePulse.Authority"); + } + if (validateConfiguration) { Validate(); @@ -57,6 +66,18 @@ public OpenIdConnectSettings(bool validateConfiguration) [JsonPropertyName("requireHttpsMetadata")] public bool RequireHttpsMetadata { get; } + [JsonPropertyName("servicePulseEnabled")] + public bool ServicePulseEnabled { get; } + + [JsonPropertyName("servicePulseAuthority")] + public string ServicePulseAuthority { get; } + + [JsonPropertyName("servicePulseClientId")] + public string ServicePulseClientId { get; } + + [JsonPropertyName("servicePulseApiScope")] + public string ServicePulseApiScope { get; } + void Validate() { if (!Enabled) @@ -112,6 +133,24 @@ void Validate() logger.LogWarning("Authentication.ValidateIssuerSigningKey is set to false. This is a serious security risk and should only be used in development environments"); } + if (ServicePulseEnabled) + { + if (string.IsNullOrWhiteSpace(ServicePulseClientId)) + { + throw new Exception("Authentication.ServicePulse.ClientId is required when Authentication.ServicePulse.Enabled is true."); + } + + if (string.IsNullOrWhiteSpace(ServicePulseApiScope)) + { + throw new Exception("Authentication.ServicePulse.ApiScope is required when Authentication.ServicePulse.Enabled is true."); + } + + if (ServicePulseAuthority != null && !Uri.TryCreate(ServicePulseAuthority, UriKind.Absolute, out _)) + { + throw new Exception("Authentication.ServicePulse.Authority must be a valid absolute URI if provided."); + } + } + logger.LogInformation("Authentication configuration validated successfully"); logger.LogInformation(" Authority: {Authority}", Authority); logger.LogInformation(" Audience: {Audience}", Audience); @@ -120,6 +159,10 @@ void Validate() logger.LogInformation(" ValidateLifetime: {ValidateLifetime}", ValidateLifetime); logger.LogInformation(" ValidateIssuerSigningKey: {ValidateIssuerSigningKey}", ValidateIssuerSigningKey); logger.LogInformation(" RequireHttpsMetadata: {RequireHttpsMetadata}", RequireHttpsMetadata); + logger.LogInformation(" ServicePulseEnabled: {ServicePulseEnabled}", ServicePulseEnabled); + logger.LogInformation(" ServicePulseClientId: {ServicePulseClientId}", ServicePulseClientId); + logger.LogInformation(" ServicePulseAuthority: {ServicePulseAuthority}", ServicePulseAuthority); + logger.LogInformation(" ServicePulseApiScope: {ServicePulseApiScope}", ServicePulseApiScope); } } } From af180a2b086be830321ad5dfb229d8234293067f Mon Sep 17 00:00:00 2001 From: Jason Taylor <1988321+jasontaylordev@users.noreply.github.com> Date: Thu, 27 Nov 2025 07:27:03 +1000 Subject: [PATCH 03/24] Update src/ServiceControl/App.config Co-authored-by: Warwick Schroeder --- src/ServiceControl/App.config | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/ServiceControl/App.config b/src/ServiceControl/App.config index 67274723e1..14f052cdd5 100644 --- a/src/ServiceControl/App.config +++ b/src/ServiceControl/App.config @@ -41,10 +41,10 @@ These settings are only here so that we can debug ServiceControl while developin --> - - - - + From 36dcabf48af9ef404982a9791bce7ac89f85c626 Mon Sep 17 00:00:00 2001 From: Jason Taylor <1988321+jasontaylordev@users.noreply.github.com> Date: Thu, 27 Nov 2025 07:27:10 +1000 Subject: [PATCH 04/24] Update src/ServiceControl/App.config Co-authored-by: Warwick Schroeder --- src/ServiceControl/App.config | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ServiceControl/App.config b/src/ServiceControl/App.config index 14f052cdd5..ace3f58d6f 100644 --- a/src/ServiceControl/App.config +++ b/src/ServiceControl/App.config @@ -31,9 +31,9 @@ These settings are only here so that we can debug ServiceControl while developin - - - + - - + + + @@ -40,11 +40,12 @@ These settings are only here so that we can debug ServiceControl while developin --> - - + + + diff --git a/src/ServiceControl/Authentication/AuthenticationController.cs b/src/ServiceControl/Authentication/AuthenticationController.cs index 732e8f4b7c..9f23036a3b 100644 --- a/src/ServiceControl/Authentication/AuthenticationController.cs +++ b/src/ServiceControl/Authentication/AuthenticationController.cs @@ -18,7 +18,8 @@ public ActionResult Configuration() Enabled = settings.OpenIdConnectSettings.Enabled, ClientId = settings.OpenIdConnectSettings.ServicePulseClientId, Authority = settings.OpenIdConnectSettings.ServicePulseAuthority, - ApiScope = settings.OpenIdConnectSettings.ServicePulseApiScope + Audience = settings.OpenIdConnectSettings.Audience, + ApiScopes = settings.OpenIdConnectSettings.ServicePulseApiScopes }; return Ok(info); @@ -30,6 +31,7 @@ public class AuthConfig public bool Enabled { get; set; } public string ClientId { get; set; } public string Authority { get; set; } - public string ApiScope { get; set; } + public string Audience { get; set; } + public string ApiScopes { get; set; } } } diff --git a/src/ServiceControl/Infrastructure/Settings/OpenIdConnectSettings.cs b/src/ServiceControl/Infrastructure/Settings/OpenIdConnectSettings.cs index 6a4f34c627..23b149c877 100644 --- a/src/ServiceControl/Infrastructure/Settings/OpenIdConnectSettings.cs +++ b/src/ServiceControl/Infrastructure/Settings/OpenIdConnectSettings.cs @@ -27,7 +27,7 @@ public OpenIdConnectSettings(bool validateConfiguration) ValidateIssuerSigningKey = SettingsReader.Read(Settings.SettingsRootNamespace, "Authentication.ValidateIssuerSigningKey", true); RequireHttpsMetadata = SettingsReader.Read(Settings.SettingsRootNamespace, "Authentication.RequireHttpsMetadata", true); ServicePulseClientId = SettingsReader.Read(Settings.SettingsRootNamespace, "Authentication.ServicePulse.ClientId"); - ServicePulseApiScope = SettingsReader.Read(Settings.SettingsRootNamespace, "Authentication.ServicePulse.ApiScope"); + ServicePulseApiScopes = SettingsReader.Read(Settings.SettingsRootNamespace, "Authentication.ServicePulse.ApiScopes"); ServicePulseAuthority = SettingsReader.Read(Settings.SettingsRootNamespace, "Authentication.ServicePulse.Authority"); if (validateConfiguration) @@ -63,11 +63,14 @@ public OpenIdConnectSettings(bool validateConfiguration) [JsonPropertyName("servicePulseAuthority")] public string ServicePulseAuthority { get; } + [JsonPropertyName("servicePulseAudience")] + public string ServicePulseAudience { get; } + [JsonPropertyName("servicePulseClientId")] public string ServicePulseClientId { get; } - [JsonPropertyName("servicePulseApiScope")] - public string ServicePulseApiScope { get; } + [JsonPropertyName("servicePulseApiScopes")] + public string ServicePulseApiScopes { get; } void Validate() { @@ -129,10 +132,10 @@ void Validate() throw new Exception("Authentication.ServicePulse.ClientId is required when Authentication.ServicePulse.Enabled is true."); } - if (string.IsNullOrWhiteSpace(ServicePulseApiScope)) - { - throw new Exception("Authentication.ServicePulse.ApiScope is required when Authentication.ServicePulse.Enabled is true."); - } + //if (string.IsNullOrWhiteSpace(ServicePulseApiScope)) + //{ + // throw new Exception("Authentication.ServicePulse.ApiScope is required when Authentication.ServicePulse.Enabled is true."); + //} if (ServicePulseAuthority != null && !Uri.TryCreate(ServicePulseAuthority, UriKind.Absolute, out _)) { @@ -149,7 +152,8 @@ void Validate() logger.LogInformation(" RequireHttpsMetadata: {RequireHttpsMetadata}", RequireHttpsMetadata); logger.LogInformation(" ServicePulseClientId: {ServicePulseClientId}", ServicePulseClientId); logger.LogInformation(" ServicePulseAuthority: {ServicePulseAuthority}", ServicePulseAuthority); - logger.LogInformation(" ServicePulseApiScope: {ServicePulseApiScope}", ServicePulseApiScope); + logger.LogInformation(" ServicePulseAudience: {ServicePulseAudience}", ServicePulseAudience); + logger.LogInformation(" ServicePulseApiScopes: {ServicePulseApiScopes}", ServicePulseApiScopes); } } } From 1b3f5dc0a64d2123ae0ceabf7f63ae94691ebf2f Mon Sep 17 00:00:00 2001 From: Jason Taylor Date: Fri, 28 Nov 2025 10:51:36 +1000 Subject: [PATCH 08/24] Add auth to other instances --- src/ServiceControl.Audit/App.config | 18 ++ .../Hosting/Commands/RunCommand.cs | 5 + .../Infrastructure/Settings/Settings.cs | 4 + .../Auth/HostApplicationBuilderExtensions.cs | 41 +++++ .../Auth/WebApplicationExtensions.cs | 21 +++ .../ServiceControl.Hosting.csproj | 10 ++ .../OpenIdConnectSettings.cs | 153 +++++++++++++++++ src/ServiceControl.Monitoring/App.config | 18 ++ .../Hosting/Commands/RunCommand.cs | 4 + src/ServiceControl.Monitoring/Settings.cs | 4 + .../Hosting/Commands/RunCommand.cs | 46 +---- .../Settings/OpenIdConnectSettings.cs | 159 ------------------ .../Infrastructure/Settings/Settings.cs | 8 +- .../WebApplicationExtensions.cs | 16 +- 14 files changed, 287 insertions(+), 220 deletions(-) create mode 100644 src/ServiceControl.Hosting/Auth/HostApplicationBuilderExtensions.cs create mode 100644 src/ServiceControl.Hosting/Auth/WebApplicationExtensions.cs create mode 100644 src/ServiceControl.Infrastructure/OpenIdConnectSettings.cs delete mode 100644 src/ServiceControl/Infrastructure/Settings/OpenIdConnectSettings.cs diff --git a/src/ServiceControl.Audit/App.config b/src/ServiceControl.Audit/App.config index 9452cd7e00..fba16acb05 100644 --- a/src/ServiceControl.Audit/App.config +++ b/src/ServiceControl.Audit/App.config @@ -25,6 +25,24 @@ These settings are only here so that we can debug ServiceControl while developin + + + + + + + + + diff --git a/src/ServiceControl.Audit/Infrastructure/Hosting/Commands/RunCommand.cs b/src/ServiceControl.Audit/Infrastructure/Hosting/Commands/RunCommand.cs index e769a4863b..07dc7f7d4a 100644 --- a/src/ServiceControl.Audit/Infrastructure/Hosting/Commands/RunCommand.cs +++ b/src/ServiceControl.Audit/Infrastructure/Hosting/Commands/RunCommand.cs @@ -3,6 +3,7 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Builder; using NServiceBus; + using ServiceControl.Hosting.Auth; using Settings; using WebApi; @@ -15,6 +16,8 @@ public override async Task Execute(HostArguments args, Settings settings) assemblyScanner.ExcludeAssemblies("ServiceControl.Plugin"); var hostBuilder = WebApplication.CreateBuilder(); + + hostBuilder.AddServiceControlAuthentication(settings.OpenIdConnectSettings); hostBuilder.AddServiceControlAudit((_, __) => { //Do nothing. The transports in NSB 8 are designed to handle broker outages. Audit ingestion will be paused when broker is unavailable. @@ -24,6 +27,8 @@ public override async Task Execute(HostArguments args, Settings settings) var app = hostBuilder.Build(); app.UseServiceControlAudit(); + app.UseServiceControlAuthentication(authenticationEnabled: settings.OpenIdConnectSettings.Enabled); + await app.RunAsync(settings.RootUrl); } } diff --git a/src/ServiceControl.Audit/Infrastructure/Settings/Settings.cs b/src/ServiceControl.Audit/Infrastructure/Settings/Settings.cs index dd409f0334..c6c9ac5a57 100644 --- a/src/ServiceControl.Audit/Infrastructure/Settings/Settings.cs +++ b/src/ServiceControl.Audit/Infrastructure/Settings/Settings.cs @@ -17,6 +17,8 @@ public Settings(string transportType = null, string persisterType = null, Loggin { LoggingSettings = loggingSettings ?? new(SettingsRootNamespace); + OpenIdConnectSettings = new OpenIdConnectSettings(SettingsRootNamespace, ValidateConfiguration); + // Overwrite the instance name if it is specified in ENVVAR, reg, or config file -- LEGACY SETTING NAME InstanceName = SettingsReader.Read(SettingsRootNamespace, "InternalQueueName", InstanceName); @@ -92,6 +94,8 @@ void LoadAuditQueueInformation() public LoggingSettings LoggingSettings { get; } + public OpenIdConnectSettings OpenIdConnectSettings { get; } + //HINT: acceptance tests only public Func MessageFilter { get; set; } diff --git a/src/ServiceControl.Hosting/Auth/HostApplicationBuilderExtensions.cs b/src/ServiceControl.Hosting/Auth/HostApplicationBuilderExtensions.cs new file mode 100644 index 0000000000..a4b66f9fea --- /dev/null +++ b/src/ServiceControl.Hosting/Auth/HostApplicationBuilderExtensions.cs @@ -0,0 +1,41 @@ +namespace ServiceControl.Hosting.Auth +{ + using System; + using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.Hosting; + using ServiceControl.Infrastructure; + + public static class HostApplicationBuilderExtensions + { + public static void AddServiceControlAuthentication(this IHostApplicationBuilder hostBuilder, OpenIdConnectSettings oidcSettings) + { + if (!oidcSettings.Enabled) + { + return; + } + + hostBuilder.Services.AddAuthentication(options => + { + options.DefaultScheme = "Bearer"; + options.DefaultChallengeScheme = "Bearer"; + }) + .AddJwtBearer("Bearer", options => + { + options.Authority = oidcSettings.Authority; + // Configure token validation parameters + options.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters + { + ValidateIssuer = oidcSettings.ValidateIssuer, + ValidateAudience = oidcSettings.ValidateAudience, + ValidateLifetime = oidcSettings.ValidateLifetime, + ValidateIssuerSigningKey = oidcSettings.ValidateIssuerSigningKey, + ValidAudience = oidcSettings.Audience, + ClockSkew = TimeSpan.FromMinutes(5) // Allow 5 minutes clock skew + }; + options.RequireHttpsMetadata = oidcSettings.RequireHttpsMetadata; + // Don't map inbound claims to legacy Microsoft claim types + options.MapInboundClaims = false; + }); + } + } +} diff --git a/src/ServiceControl.Hosting/Auth/WebApplicationExtensions.cs b/src/ServiceControl.Hosting/Auth/WebApplicationExtensions.cs new file mode 100644 index 0000000000..c78b83a7b6 --- /dev/null +++ b/src/ServiceControl.Hosting/Auth/WebApplicationExtensions.cs @@ -0,0 +1,21 @@ +namespace ServiceControl.Hosting.Auth +{ + using Microsoft.AspNetCore.Builder; + + public static class WebApplicationExtensions + { + public static void UseServiceControlAuthentication(this WebApplication app, bool authenticationEnabled = false) + { + if (authenticationEnabled) + { + app.UseAuthentication(); + app.UseAuthorization(); + app.MapControllers().RequireAuthorization(); + } + else + { + app.MapControllers(); + } + } + } +} diff --git a/src/ServiceControl.Hosting/ServiceControl.Hosting.csproj b/src/ServiceControl.Hosting/ServiceControl.Hosting.csproj index 074686312c..bf3d228fcf 100644 --- a/src/ServiceControl.Hosting/ServiceControl.Hosting.csproj +++ b/src/ServiceControl.Hosting/ServiceControl.Hosting.csproj @@ -5,8 +5,18 @@ + + + + + + + + + + diff --git a/src/ServiceControl.Infrastructure/OpenIdConnectSettings.cs b/src/ServiceControl.Infrastructure/OpenIdConnectSettings.cs new file mode 100644 index 0000000000..45f7d92b49 --- /dev/null +++ b/src/ServiceControl.Infrastructure/OpenIdConnectSettings.cs @@ -0,0 +1,153 @@ +namespace ServiceControl.Infrastructure; + +using System; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; +using ServiceControl.Configuration; + +public class OpenIdConnectSettings +{ + readonly ILogger logger = LoggerUtil.CreateStaticLogger(); + + public OpenIdConnectSettings(SettingsRootNamespace rootNamespace, bool validateConfiguration) + { + Enabled = SettingsReader.Read(rootNamespace, "Authentication.Enabled", false); + + if (!Enabled) + { + return; + } + + Authority = SettingsReader.Read(rootNamespace, "Authentication.Authority"); + Audience = SettingsReader.Read(rootNamespace, "Authentication.Audience"); + ValidateIssuer = SettingsReader.Read(rootNamespace, "Authentication.ValidateIssuer", true); + ValidateAudience = SettingsReader.Read(rootNamespace, "Authentication.ValidateAudience", true); + ValidateLifetime = SettingsReader.Read(rootNamespace, "Authentication.ValidateLifetime", true); + ValidateIssuerSigningKey = SettingsReader.Read(rootNamespace, "Authentication.ValidateIssuerSigningKey", true); + RequireHttpsMetadata = SettingsReader.Read(rootNamespace, "Authentication.RequireHttpsMetadata", true); + ServicePulseClientId = SettingsReader.Read(rootNamespace, "Authentication.ServicePulse.ClientId"); + ServicePulseApiScope = SettingsReader.Read(rootNamespace, "Authentication.ServicePulse.ApiScope"); + ServicePulseAuthority = SettingsReader.Read(rootNamespace, "Authentication.ServicePulse.Authority"); + + if (validateConfiguration) + { + Validate(); + } + } + + [JsonPropertyName("enabled")] + public bool Enabled { get; } + + [JsonPropertyName("authority")] + public string Authority { get; } + + [JsonPropertyName("audience")] + public string Audience { get; } + + [JsonPropertyName("validateIssuer")] + public bool ValidateIssuer { get; } + + [JsonPropertyName("validateAudience")] + public bool ValidateAudience { get; } + + [JsonPropertyName("validateLifetime")] + public bool ValidateLifetime { get; } + + [JsonPropertyName("validateIssuerSigningKey")] + public bool ValidateIssuerSigningKey { get; } + + [JsonPropertyName("requireHttpsMetadata")] + public bool RequireHttpsMetadata { get; } + + [JsonPropertyName("servicePulseAuthority")] + public string ServicePulseAuthority { get; } + + [JsonPropertyName("servicePulseClientId")] + public string ServicePulseClientId { get; } + + [JsonPropertyName("servicePulseApiScope")] + public string ServicePulseApiScope { get; } + + void Validate() + { + if (!Enabled) + { + return; + } + + if (string.IsNullOrWhiteSpace(Authority)) + { + var message = "Authentication.Authority is required when authentication is enabled. Please provide a valid OpenID Connect authority URL (e.g., https://login.microsoftonline.com/{tenant-id}/v2.0)"; + logger.LogCritical(message); + throw new Exception(message); + } + + if (!Uri.TryCreate(Authority, UriKind.Absolute, out var authorityUri)) + { + var message = $"Authentication.Authority must be a valid absolute URI. Current value: '{Authority}'"; + logger.LogCritical(message); + throw new Exception(message); + } + + if (RequireHttpsMetadata && authorityUri.Scheme != Uri.UriSchemeHttps) + { + var message = $"Authentication.Authority must use HTTPS when RequireHttpsMetadata is true. Current value: '{Authority}'. Either use HTTPS or set Authentication.RequireHttpsMetadata to false (not recommended for production)"; + logger.LogCritical(message); + throw new Exception(message); + } + + if (string.IsNullOrWhiteSpace(Audience)) + { + var message = "Authentication.Audience is required when authentication is enabled. Please provide a valid audience identifier (typically your API identifier or client ID)"; + logger.LogCritical(message); + throw new Exception(message); + } + + if (!ValidateIssuer) + { + logger.LogWarning("Authentication.ValidateIssuer is set to false. This is not recommended for production environments as it may allow tokens from untrusted issuers"); + } + + if (!ValidateAudience) + { + logger.LogWarning("Authentication.ValidateAudience is set to false. This is not recommended for production environments as it may allow tokens intended for other applications"); + } + + if (!ValidateLifetime) + { + logger.LogWarning("Authentication.ValidateLifetime is set to false. This is not recommended as it may allow expired tokens to be accepted"); + } + + if (!ValidateIssuerSigningKey) + { + logger.LogWarning("Authentication.ValidateIssuerSigningKey is set to false. This is a serious security risk and should only be used in development environments"); + } + + if (string.IsNullOrWhiteSpace(ServicePulseClientId)) + { + throw new Exception("Authentication.ServicePulse.ClientId is required when Authentication.ServicePulse.Enabled is true."); + } + + if (string.IsNullOrWhiteSpace(ServicePulseApiScope)) + { + throw new Exception("Authentication.ServicePulse.ApiScope is required when Authentication.ServicePulse.Enabled is true."); + } + + if (ServicePulseAuthority != null && !Uri.TryCreate(ServicePulseAuthority, UriKind.Absolute, out _)) + { + throw new Exception("Authentication.ServicePulse.Authority must be a valid absolute URI if provided."); + } + + logger.LogInformation("Authentication configuration validated successfully"); + logger.LogInformation(" Authority: {Authority}", Authority); + logger.LogInformation(" Audience: {Audience}", Audience); + logger.LogInformation(" ValidateIssuer: {ValidateIssuer}", ValidateIssuer); + logger.LogInformation(" ValidateAudience: {ValidateAudience}", ValidateAudience); + logger.LogInformation(" ValidateLifetime: {ValidateLifetime}", ValidateLifetime); + logger.LogInformation(" ValidateIssuerSigningKey: {ValidateIssuerSigningKey}", ValidateIssuerSigningKey); + logger.LogInformation(" RequireHttpsMetadata: {RequireHttpsMetadata}", RequireHttpsMetadata); + logger.LogInformation(" ServicePulseClientId: {ServicePulseClientId}", ServicePulseClientId); + logger.LogInformation(" ServicePulseAuthority: {ServicePulseAuthority}", ServicePulseAuthority); + logger.LogInformation(" ServicePulseApiScope: {ServicePulseApiScope}", ServicePulseApiScope); + } +} diff --git a/src/ServiceControl.Monitoring/App.config b/src/ServiceControl.Monitoring/App.config index 0a2fa4d478..33195aae39 100644 --- a/src/ServiceControl.Monitoring/App.config +++ b/src/ServiceControl.Monitoring/App.config @@ -22,6 +22,24 @@ These settings are only here so that we can debug ServiceControl while developin + + + + + + + + + diff --git a/src/ServiceControl.Monitoring/Hosting/Commands/RunCommand.cs b/src/ServiceControl.Monitoring/Hosting/Commands/RunCommand.cs index 340c12fc08..7f672f41bb 100644 --- a/src/ServiceControl.Monitoring/Hosting/Commands/RunCommand.cs +++ b/src/ServiceControl.Monitoring/Hosting/Commands/RunCommand.cs @@ -5,6 +5,7 @@ namespace ServiceControl.Monitoring using Infrastructure.WebApi; using Microsoft.AspNetCore.Builder; using NServiceBus; + using ServiceControl.Hosting.Auth; class RunCommand : AbstractCommand { @@ -13,11 +14,14 @@ public override async Task Execute(HostArguments args, Settings settings) var endpointConfiguration = new EndpointConfiguration(settings.InstanceName); var hostBuilder = WebApplication.CreateBuilder(); + hostBuilder.AddServiceControlAuthentication(settings.OpenIdConnectSettings); hostBuilder.AddServiceControlMonitoring((_, __) => Task.CompletedTask, settings, endpointConfiguration); hostBuilder.AddServiceControlMonitoringApi(); var app = hostBuilder.Build(); app.UseServiceControlMonitoring(); + app.UseServiceControlAuthentication(authenticationEnabled: settings.OpenIdConnectSettings.Enabled); + await app.RunAsync(settings.RootUrl); } } diff --git a/src/ServiceControl.Monitoring/Settings.cs b/src/ServiceControl.Monitoring/Settings.cs index 3412307042..361cf785a8 100644 --- a/src/ServiceControl.Monitoring/Settings.cs +++ b/src/ServiceControl.Monitoring/Settings.cs @@ -16,6 +16,8 @@ public Settings(LoggingSettings loggingSettings = null, string transportType = n { LoggingSettings = loggingSettings ?? new(SettingsRootNamespace); + OpenIdConnectSettings = new OpenIdConnectSettings(SettingsRootNamespace, false); + // Overwrite the instance name if it is specified in ENVVAR, reg, or config file InstanceName = SettingsReader.Read(SettingsRootNamespace, "InstanceName", InstanceName); @@ -49,6 +51,8 @@ public Settings(LoggingSettings loggingSettings = null, string transportType = n public LoggingSettings LoggingSettings { get; } + public OpenIdConnectSettings OpenIdConnectSettings { get; } + public string InstanceName { get; init; } = DEFAULT_INSTANCE_NAME; public string TransportType { get; set; } diff --git a/src/ServiceControl/Hosting/Commands/RunCommand.cs b/src/ServiceControl/Hosting/Commands/RunCommand.cs index 5df4ceb8df..1c2da74f43 100644 --- a/src/ServiceControl/Hosting/Commands/RunCommand.cs +++ b/src/ServiceControl/Hosting/Commands/RunCommand.cs @@ -1,18 +1,14 @@ namespace ServiceControl.Hosting.Commands { - using System; using System.Threading.Tasks; using Infrastructure.WebApi; - using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Builder; - using Microsoft.Extensions.DependencyInjection; - using Microsoft.IdentityModel.JsonWebTokens; - using Microsoft.IdentityModel.Tokens; using NServiceBus; using Particular.ServiceControl; using Particular.ServiceControl.Hosting; using ServiceBus.Management.Infrastructure.Settings; using ServiceControl; + using ServiceControl.Hosting.Auth; class RunCommand : AbstractCommand { @@ -26,47 +22,13 @@ public override async Task Execute(HostArguments args, Settings settings) var hostBuilder = WebApplication.CreateBuilder(); - // Configure JWT Bearer Authentication with OpenID Connect - if (settings.OpenIdConnectSettings.Enabled) - { - hostBuilder.Services.AddAuthentication(options => - { - options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme; - options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; - }) - .AddJwtBearer(options => - { - var oidcSettings = settings.OpenIdConnectSettings; - - options.Authority = oidcSettings.Authority; - - // Configure token validation parameters - options.TokenValidationParameters = new TokenValidationParameters - { - ValidateIssuer = oidcSettings.ValidateIssuer, - ValidateAudience = oidcSettings.ValidateAudience, - ValidateLifetime = oidcSettings.ValidateLifetime, - ValidateIssuerSigningKey = oidcSettings.ValidateIssuerSigningKey, - ValidAudience = oidcSettings.Audience, - ClockSkew = TimeSpan.FromMinutes(5) // Allow 5 minutes clock skew - }; - - options.RequireHttpsMetadata = oidcSettings.RequireHttpsMetadata; - - // Don't map inbound claims to legacy Microsoft claim types - options.MapInboundClaims = false; - }); - - // Clear the default claim type mappings to use standard JWT claim names - JsonWebTokenHandler.DefaultInboundClaimTypeMap.Clear(); - } - - + hostBuilder.AddServiceControlAuthentication(settings.OpenIdConnectSettings); hostBuilder.AddServiceControl(settings, endpointConfiguration); hostBuilder.AddServiceControlApi(); var app = hostBuilder.Build(); - app.UseServiceControl(authenticationEnabled: settings.OpenIdConnectSettings.Enabled); + app.UseServiceControl(); + app.UseServiceControlAuthentication(authenticationEnabled: settings.OpenIdConnectSettings.Enabled); await app.RunAsync(settings.RootUrl); } diff --git a/src/ServiceControl/Infrastructure/Settings/OpenIdConnectSettings.cs b/src/ServiceControl/Infrastructure/Settings/OpenIdConnectSettings.cs deleted file mode 100644 index 23b149c877..0000000000 --- a/src/ServiceControl/Infrastructure/Settings/OpenIdConnectSettings.cs +++ /dev/null @@ -1,159 +0,0 @@ -namespace ServiceBus.Management.Infrastructure.Settings -{ - using System; - using System.Text.Json.Serialization; - using Microsoft.Extensions.Logging; - using ServiceControl.Configuration; - using ServiceControl.Infrastructure; - - public class OpenIdConnectSettings - { - readonly ILogger logger = LoggerUtil.CreateStaticLogger(); - - public OpenIdConnectSettings(bool validateConfiguration) - { - Enabled = SettingsReader.Read(Settings.SettingsRootNamespace, "Authentication.Enabled", false); - - if (!Enabled) - { - return; - } - - Authority = SettingsReader.Read(Settings.SettingsRootNamespace, "Authentication.Authority"); - Audience = SettingsReader.Read(Settings.SettingsRootNamespace, "Authentication.Audience"); - ValidateIssuer = SettingsReader.Read(Settings.SettingsRootNamespace, "Authentication.ValidateIssuer", true); - ValidateAudience = SettingsReader.Read(Settings.SettingsRootNamespace, "Authentication.ValidateAudience", true); - ValidateLifetime = SettingsReader.Read(Settings.SettingsRootNamespace, "Authentication.ValidateLifetime", true); - ValidateIssuerSigningKey = SettingsReader.Read(Settings.SettingsRootNamespace, "Authentication.ValidateIssuerSigningKey", true); - RequireHttpsMetadata = SettingsReader.Read(Settings.SettingsRootNamespace, "Authentication.RequireHttpsMetadata", true); - ServicePulseClientId = SettingsReader.Read(Settings.SettingsRootNamespace, "Authentication.ServicePulse.ClientId"); - ServicePulseApiScopes = SettingsReader.Read(Settings.SettingsRootNamespace, "Authentication.ServicePulse.ApiScopes"); - ServicePulseAuthority = SettingsReader.Read(Settings.SettingsRootNamespace, "Authentication.ServicePulse.Authority"); - - if (validateConfiguration) - { - Validate(); - } - } - - [JsonPropertyName("enabled")] - public bool Enabled { get; } - - [JsonPropertyName("authority")] - public string Authority { get; } - - [JsonPropertyName("audience")] - public string Audience { get; } - - [JsonPropertyName("validateIssuer")] - public bool ValidateIssuer { get; } - - [JsonPropertyName("validateAudience")] - public bool ValidateAudience { get; } - - [JsonPropertyName("validateLifetime")] - public bool ValidateLifetime { get; } - - [JsonPropertyName("validateIssuerSigningKey")] - public bool ValidateIssuerSigningKey { get; } - - [JsonPropertyName("requireHttpsMetadata")] - public bool RequireHttpsMetadata { get; } - - [JsonPropertyName("servicePulseAuthority")] - public string ServicePulseAuthority { get; } - - [JsonPropertyName("servicePulseAudience")] - public string ServicePulseAudience { get; } - - [JsonPropertyName("servicePulseClientId")] - public string ServicePulseClientId { get; } - - [JsonPropertyName("servicePulseApiScopes")] - public string ServicePulseApiScopes { get; } - - void Validate() - { - if (!Enabled) - { - return; - } - - if (string.IsNullOrWhiteSpace(Authority)) - { - var message = "Authentication.Authority is required when authentication is enabled. Please provide a valid OpenID Connect authority URL (e.g., https://login.microsoftonline.com/{tenant-id}/v2.0)"; - logger.LogCritical(message); - throw new Exception(message); - } - - if (!Uri.TryCreate(Authority, UriKind.Absolute, out var authorityUri)) - { - var message = $"Authentication.Authority must be a valid absolute URI. Current value: '{Authority}'"; - logger.LogCritical(message); - throw new Exception(message); - } - - if (RequireHttpsMetadata && authorityUri.Scheme != Uri.UriSchemeHttps) - { - var message = $"Authentication.Authority must use HTTPS when RequireHttpsMetadata is true. Current value: '{Authority}'. Either use HTTPS or set Authentication.RequireHttpsMetadata to false (not recommended for production)"; - logger.LogCritical(message); - throw new Exception(message); - } - - if (string.IsNullOrWhiteSpace(Audience)) - { - var message = "Authentication.Audience is required when authentication is enabled. Please provide a valid audience identifier (typically your API identifier or client ID)"; - logger.LogCritical(message); - throw new Exception(message); - } - - if (!ValidateIssuer) - { - logger.LogWarning("Authentication.ValidateIssuer is set to false. This is not recommended for production environments as it may allow tokens from untrusted issuers"); - } - - if (!ValidateAudience) - { - logger.LogWarning("Authentication.ValidateAudience is set to false. This is not recommended for production environments as it may allow tokens intended for other applications"); - } - - if (!ValidateLifetime) - { - logger.LogWarning("Authentication.ValidateLifetime is set to false. This is not recommended as it may allow expired tokens to be accepted"); - } - - if (!ValidateIssuerSigningKey) - { - logger.LogWarning("Authentication.ValidateIssuerSigningKey is set to false. This is a serious security risk and should only be used in development environments"); - } - - if (string.IsNullOrWhiteSpace(ServicePulseClientId)) - { - throw new Exception("Authentication.ServicePulse.ClientId is required when Authentication.ServicePulse.Enabled is true."); - } - - //if (string.IsNullOrWhiteSpace(ServicePulseApiScope)) - //{ - // throw new Exception("Authentication.ServicePulse.ApiScope is required when Authentication.ServicePulse.Enabled is true."); - //} - - if (ServicePulseAuthority != null && !Uri.TryCreate(ServicePulseAuthority, UriKind.Absolute, out _)) - { - throw new Exception("Authentication.ServicePulse.Authority must be a valid absolute URI if provided."); - } - - logger.LogInformation("Authentication configuration validated successfully"); - logger.LogInformation(" Authority: {Authority}", Authority); - logger.LogInformation(" Audience: {Audience}", Audience); - logger.LogInformation(" ValidateIssuer: {ValidateIssuer}", ValidateIssuer); - logger.LogInformation(" ValidateAudience: {ValidateAudience}", ValidateAudience); - logger.LogInformation(" ValidateLifetime: {ValidateLifetime}", ValidateLifetime); - logger.LogInformation(" ValidateIssuerSigningKey: {ValidateIssuerSigningKey}", ValidateIssuerSigningKey); - logger.LogInformation(" RequireHttpsMetadata: {RequireHttpsMetadata}", RequireHttpsMetadata); - logger.LogInformation(" ServicePulseClientId: {ServicePulseClientId}", ServicePulseClientId); - logger.LogInformation(" ServicePulseAuthority: {ServicePulseAuthority}", ServicePulseAuthority); - logger.LogInformation(" ServicePulseAudience: {ServicePulseAudience}", ServicePulseAudience); - logger.LogInformation(" ServicePulseApiScopes: {ServicePulseApiScopes}", ServicePulseApiScopes); - } - } -} diff --git a/src/ServiceControl/Infrastructure/Settings/Settings.cs b/src/ServiceControl/Infrastructure/Settings/Settings.cs index 714f0ee6e3..e6ee8b95e1 100644 --- a/src/ServiceControl/Infrastructure/Settings/Settings.cs +++ b/src/ServiceControl/Infrastructure/Settings/Settings.cs @@ -29,6 +29,8 @@ public Settings( { LoggingSettings = loggingSettings ?? new(SettingsRootNamespace); + OpenIdConnectSettings = new OpenIdConnectSettings(SettingsRootNamespace, ValidateConfiguration); + // Overwrite the instance name if it is specified in ENVVAR, reg, or config file -- LEGACY SETTING NAME InstanceName = SettingsReader.Read(SettingsRootNamespace, "InternalQueueName", InstanceName); @@ -37,8 +39,6 @@ public Settings( LoadErrorIngestionSettings(); - OpenIdConnectSettings = new OpenIdConnectSettings(ValidateConfiguration); - TransportConnectionString = GetConnectionString(); TransportType = transportType ?? SettingsReader.Read(SettingsRootNamespace, "TransportType"); PersistenceType = persisterType ?? SettingsReader.Read(SettingsRootNamespace, "PersistenceType"); @@ -76,6 +76,8 @@ public Settings( public LoggingSettings LoggingSettings { get; } + public OpenIdConnectSettings OpenIdConnectSettings { get; } + public string NotificationsFilter { get; set; } public bool AllowMessageEditing { get; set; } @@ -183,8 +185,6 @@ public TimeSpan HeartbeatGracePeriod public bool DisableHealthChecks { get; set; } - public OpenIdConnectSettings OpenIdConnectSettings { get; } - // The default value is set to the maximum allowed time by the most // restrictive hosting platform, which is Linux containers. Linux // containers allow for a maximum of 10 seconds. We set it to 5 to diff --git a/src/ServiceControl/WebApplicationExtensions.cs b/src/ServiceControl/WebApplicationExtensions.cs index 5d3229eab6..407ad00c9c 100644 --- a/src/ServiceControl/WebApplicationExtensions.cs +++ b/src/ServiceControl/WebApplicationExtensions.cs @@ -7,7 +7,7 @@ namespace ServiceControl; public static class WebApplicationExtensions { - public static void UseServiceControl(this WebApplication app, bool authenticationEnabled = false) + public static void UseServiceControl(this WebApplication app) { app.UseForwardedHeaders(new ForwardedHeadersOptions { ForwardedHeaders = ForwardedHeaders.All }); app.UseResponseCompression(); @@ -15,19 +15,5 @@ public static void UseServiceControl(this WebApplication app, bool authenticatio app.UseHttpLogging(); app.MapHub("/api/messagestream"); app.UseCors(); - - // Always add middleware (harmless when not configured) - app.UseAuthentication(); - app.UseAuthorization(); - - // Only require authorization if authentication is enabled - if (authenticationEnabled) - { - app.MapControllers().RequireAuthorization(); - } - else - { - app.MapControllers(); - } } } \ No newline at end of file From 87e6f9502067064c298805a5c293e844a5913774 Mon Sep 17 00:00:00 2001 From: Jason Taylor Date: Fri, 28 Nov 2025 11:13:22 +1000 Subject: [PATCH 09/24] Rename to ApiScopes --- src/ServiceControl.Audit/App.config | 2 +- .../OpenIdConnectSettings.cs | 12 ++++++------ src/ServiceControl.Monitoring/App.config | 2 +- .../APIApprovals.PlatformSampleSettings.approved.txt | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/ServiceControl.Audit/App.config b/src/ServiceControl.Audit/App.config index fba16acb05..22a191271b 100644 --- a/src/ServiceControl.Audit/App.config +++ b/src/ServiceControl.Audit/App.config @@ -42,7 +42,7 @@ These settings are only here so that we can debug ServiceControl while developin + --> diff --git a/src/ServiceControl.Infrastructure/OpenIdConnectSettings.cs b/src/ServiceControl.Infrastructure/OpenIdConnectSettings.cs index 45f7d92b49..6443b0198e 100644 --- a/src/ServiceControl.Infrastructure/OpenIdConnectSettings.cs +++ b/src/ServiceControl.Infrastructure/OpenIdConnectSettings.cs @@ -26,7 +26,7 @@ public OpenIdConnectSettings(SettingsRootNamespace rootNamespace, bool validateC ValidateIssuerSigningKey = SettingsReader.Read(rootNamespace, "Authentication.ValidateIssuerSigningKey", true); RequireHttpsMetadata = SettingsReader.Read(rootNamespace, "Authentication.RequireHttpsMetadata", true); ServicePulseClientId = SettingsReader.Read(rootNamespace, "Authentication.ServicePulse.ClientId"); - ServicePulseApiScope = SettingsReader.Read(rootNamespace, "Authentication.ServicePulse.ApiScope"); + ServicePulseApiScopes = SettingsReader.Read(rootNamespace, "Authentication.ServicePulse.ApiScopes"); ServicePulseAuthority = SettingsReader.Read(rootNamespace, "Authentication.ServicePulse.Authority"); if (validateConfiguration) @@ -65,8 +65,8 @@ public OpenIdConnectSettings(SettingsRootNamespace rootNamespace, bool validateC [JsonPropertyName("servicePulseClientId")] public string ServicePulseClientId { get; } - [JsonPropertyName("servicePulseApiScope")] - public string ServicePulseApiScope { get; } + [JsonPropertyName("servicePulseApiScopes")] + public string ServicePulseApiScopes { get; } void Validate() { @@ -128,9 +128,9 @@ void Validate() throw new Exception("Authentication.ServicePulse.ClientId is required when Authentication.ServicePulse.Enabled is true."); } - if (string.IsNullOrWhiteSpace(ServicePulseApiScope)) + if (string.IsNullOrWhiteSpace(ServicePulseApiScopes)) { - throw new Exception("Authentication.ServicePulse.ApiScope is required when Authentication.ServicePulse.Enabled is true."); + throw new Exception("Authentication.ServicePulse.ApiScopes is required when Authentication.ServicePulse.Enabled is true."); } if (ServicePulseAuthority != null && !Uri.TryCreate(ServicePulseAuthority, UriKind.Absolute, out _)) @@ -148,6 +148,6 @@ void Validate() logger.LogInformation(" RequireHttpsMetadata: {RequireHttpsMetadata}", RequireHttpsMetadata); logger.LogInformation(" ServicePulseClientId: {ServicePulseClientId}", ServicePulseClientId); logger.LogInformation(" ServicePulseAuthority: {ServicePulseAuthority}", ServicePulseAuthority); - logger.LogInformation(" ServicePulseApiScope: {ServicePulseApiScope}", ServicePulseApiScope); + logger.LogInformation(" ServicePulseApiScopes: {ServicePulseApiScopes}", ServicePulseApiScopes); } } diff --git a/src/ServiceControl.Monitoring/App.config b/src/ServiceControl.Monitoring/App.config index 33195aae39..62f4a54778 100644 --- a/src/ServiceControl.Monitoring/App.config +++ b/src/ServiceControl.Monitoring/App.config @@ -39,7 +39,7 @@ These settings are only here so that we can debug ServiceControl while developin + --> diff --git a/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt b/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt index 96c93cbbb4..4b8cd828ba 100644 --- a/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt +++ b/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt @@ -51,7 +51,7 @@ "requireHttpsMetadata": false, "servicePulseAuthority": null, "servicePulseClientId": null, - "servicePulseApiScope": null + "servicePulseApiScopes": null }, "ShutdownTimeout": "00:00:05" } \ No newline at end of file From 44ef6dab040ba39df5bed605bcc5c0818788c45f Mon Sep 17 00:00:00 2001 From: Warwick Schroeder Date: Wed, 3 Dec 2025 15:12:42 +0800 Subject: [PATCH 10/24] Add additional options for flexible and secure hosting; SSL/TLS, Reverse Proxy, Direct HTTPS, CORS --- .gitignore | 7 + docs/hosting-guide.md | 368 ++++++++++++++++++ docs/local-https-testing.md | 196 ++++++++++ docs/local-nginx-testing.md | 345 ++++++++++++++++ .../ServiceControlComponentRunner.cs | 4 +- .../ServiceControlComponentRunner.cs | 4 +- ...rovals.PlatformSampleSettings.approved.txt | 32 ++ src/ServiceControl.Audit/App.config | 51 ++- .../Hosting/Commands/RunCommand.cs | 6 +- .../Infrastructure/Settings/Settings.cs | 14 +- .../Infrastructure/WebApi/Cors.cs | 16 +- .../HostApplicationBuilderExtensions.cs | 5 +- .../Properties/launchSettings.json | 1 + .../WebApplicationExtensions.cs | 9 +- .../Auth/HostApplicationBuilderExtensions.cs | 100 ++++- .../WebApplicationExtensions.cs | 39 ++ .../Https/HostApplicationBuilderExtensions.cs | 45 +++ .../Https/WebApplicationExtensions.cs | 21 + .../CorsSettings.cs | 80 ++++ .../ForwardedHeadersSettings.cs | 142 +++++++ .../HttpsSettings.cs | 106 +++++ .../OpenIdConnectSettings.cs | 55 ++- .../ServiceControlComponentRunner.cs | 2 +- ...sTests.PlatformSampleSettings.approved.txt | 33 ++ src/ServiceControl.Monitoring/App.config | 51 ++- .../Hosting/Commands/RunCommand.cs | 4 +- .../Properties/launchSettings.json | 1 + src/ServiceControl.Monitoring/Settings.cs | 15 +- .../WebApplicationExtensions.cs | 22 +- .../ErrorMessagesDataStore.cs | 4 +- .../RetryHistory.cs | 2 +- .../CustomTimeSpanConverter.cs | 6 +- ...rovals.PlatformSampleSettings.approved.txt | 45 ++- src/ServiceControl/App.config | 56 ++- .../AuthenticationController.cs | 3 + .../GetAuditCountsForEndpointApi.cs | 2 +- .../Commands/ImportFailedErrorsCommand.cs | 2 +- .../Hosting/Commands/RunCommand.cs | 8 +- .../Infrastructure/Settings/Settings.cs | 12 +- .../Infrastructure/WebApi/Cors.cs | 13 +- .../HostApplicationBuilderExtensions.cs | 22 +- .../Operations/ErrorProcessor.cs | 2 +- .../Properties/launchSettings.json | 1 + .../WebApplicationExtensions.cs | 10 +- 44 files changed, 1837 insertions(+), 125 deletions(-) create mode 100644 docs/hosting-guide.md create mode 100644 docs/local-https-testing.md create mode 100644 docs/local-nginx-testing.md create mode 100644 src/ServiceControl.Hosting/ForwardedHeaders/WebApplicationExtensions.cs create mode 100644 src/ServiceControl.Hosting/Https/HostApplicationBuilderExtensions.cs create mode 100644 src/ServiceControl.Hosting/Https/WebApplicationExtensions.cs create mode 100644 src/ServiceControl.Infrastructure/CorsSettings.cs create mode 100644 src/ServiceControl.Infrastructure/ForwardedHeadersSettings.cs create mode 100644 src/ServiceControl.Infrastructure/HttpsSettings.cs diff --git a/.gitignore b/.gitignore index 5bf25ccac6..ac86e8d3b5 100644 --- a/.gitignore +++ b/.gitignore @@ -101,3 +101,10 @@ src/scaffolding.config # Visual Studio Code .vscode + +# AI config +.claude/ +CLAUDE.md + +# User-specific files +.local \ No newline at end of file diff --git a/docs/hosting-guide.md b/docs/hosting-guide.md new file mode 100644 index 0000000000..0f620d968b --- /dev/null +++ b/docs/hosting-guide.md @@ -0,0 +1,368 @@ +# ServiceControl Hosting and Security Guide + +This guide covers all hosting and security options available for ServiceControl, ServiceControl.Audit, and ServiceControl.Monitoring instances. + +## Configuration Settings by Instance + +Each ServiceControl instance uses a different configuration prefix: + +| Instance | Configuration Prefix | Default Port | +|----------|---------------------|--------------| +| ServiceControl (Primary) | `ServiceControl/` | 33333 | +| ServiceControl.Audit | `ServiceControl.Audit/` | 44444 | +| ServiceControl.Monitoring | `Monitoring/` | 33633 | + +Settings can be configured via: + +- App.config / Web.config files +- Windows Registry (legacy) + +--- + +## Hosting Model + +ServiceControl runs as a standalone Windows service with Kestrel as the built-in web server. It does not support being hosted inside IIS (in-process hosting). + +If you place IIS, nginx, or another web server in front of ServiceControl, it acts as a **reverse proxy** forwarding requests to Kestrel. + +--- + +## Deployment Scenarios + +### Scenario 1: Default Configuration + +The default configuration with no additional setup required. Backwards compatible with existing deployments. + +**Security Features:** + +| Feature | Status | +|---------|--------| +| JWT Authentication | ❌ Disabled | +| Kestrel HTTPS | ❌ Disabled | +| HTTPS Redirection | ❌ Disabled | +| HSTS | ❌ Disabled | +| Restricted CORS Origins | ❌ Disabled (any origin) | +| Forwarded Headers | ✅ Enabled (trusts all) | +| Restricted Proxy Trust | ❌ Disabled | + +```xml + + + + + +``` + +Or explicitly: + +```xml + + + + + + + +``` + +--- + +### Scenario 2: Reverse Proxy with SSL Termination + +ServiceControl behind a reverse proxy (nginx, IIS, cloud load balancer) that handles SSL/TLS termination. + +**Security Features:** + +| Feature | Status | +|---------|--------| +| JWT Authentication | ❌ Disabled | +| Kestrel HTTPS | ❌ Disabled (handled by proxy) | +| HTTPS Redirection | ❌ Disabled (handled by proxy) | +| HSTS | ❌ Disabled (handled by proxy) | +| Restricted CORS Origins | ✅ Enabled | +| Forwarded Headers | ✅ Enabled | +| Restricted Proxy Trust | ✅ Enabled | + +```xml + + + + + + + + + + + + +``` + +--- + +### Scenario 3: Direct HTTPS (No Reverse Proxy) + +Kestrel handles TLS directly without a reverse proxy. + +**Security Features:** + +| Feature | Status | +|---------|--------| +| JWT Authentication | ❌ Disabled | +| Kestrel HTTPS | ✅ Enabled | +| HTTPS Redirection | ✅ Enabled | +| HSTS | ✅ Enabled | +| Restricted CORS Origins | ✅ Enabled | +| Forwarded Headers | ❌ Disabled (no proxy) | +| Restricted Proxy Trust | N/A | + +```xml + + + + + + + + + + + + + + + + + + + +``` + +--- + +### Scenario 4: Reverse Proxy with Authentication + +Reverse proxy with SSL termination and JWT authentication via an identity provider (Azure AD, Okta, Auth0, Keycloak, etc.). + +**Security Features:** + +| Feature | Status | +|---------|--------| +| JWT Authentication | ✅ Enabled | +| Kestrel HTTPS | ❌ Disabled (handled by proxy) | +| HTTPS Redirection | ❌ Disabled (handled by proxy) | +| HSTS | ❌ Disabled (handled by proxy) | +| Restricted CORS Origins | ✅ Enabled | +| Forwarded Headers | ✅ Enabled | +| Restricted Proxy Trust | ✅ Enabled | + +```xml + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +> **Note:** The token validation settings (`ValidateIssuer`, `ValidateAudience`, `ValidateLifetime`, `ValidateIssuerSigningKey`, `RequireHttpsMetadata`) all default to `true`. + +--- + +### Scenario 5: Direct HTTPS with Authentication + +Kestrel handles TLS directly with JWT authentication. No reverse proxy. + +**Security Features:** + +| Feature | Status | +|---------|--------| +| JWT Authentication | ✅ Enabled | +| Kestrel HTTPS | ✅ Enabled | +| HTTPS Redirection | ✅ Enabled | +| HSTS | ✅ Enabled | +| Restricted CORS Origins | ✅ Enabled | +| Forwarded Headers | ❌ Disabled (no proxy) | +| Restricted Proxy Trust | N/A | + +```xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +--- + +### Scenario 6: End-to-End Encryption with Reverse Proxy and Authentication + +End-to-end TLS encryption where the reverse proxy terminates external TLS and re-encrypts traffic to ServiceControl over HTTPS. Includes JWT authentication. + +**Security Features:** + +| Feature | Status | +|---------|--------| +| JWT Authentication | ✅ Enabled | +| Kestrel HTTPS | ✅ Enabled | +| HTTPS Redirection | ❌ Disabled (handled by proxy) | +| HSTS | ❌ Disabled (handled by proxy) | +| Restricted CORS Origins | ✅ Enabled | +| Forwarded Headers | ✅ Enabled | +| Restricted Proxy Trust | ✅ Enabled | + +```xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +--- + +## Configuration Reference + +### Authentication Settings + +| Setting | Type | Default | Description | +|---------|------|---------|-------------| +| `Authentication.Enabled` | bool | `false` | Enable JWT Bearer authentication | +| `Authentication.Authority` | string | - | OpenID Connect authority URL (required when enabled) | +| `Authentication.Audience` | string | - | Expected audience for tokens (required when enabled) | +| `Authentication.ValidateIssuer` | bool | `true` | Validate token issuer | +| `Authentication.ValidateAudience` | bool | `true` | Validate token audience | +| `Authentication.ValidateLifetime` | bool | `true` | Validate token expiration | +| `Authentication.ValidateIssuerSigningKey` | bool | `true` | Validate token signing key | +| `Authentication.RequireHttpsMetadata` | bool | `true` | Require HTTPS for metadata endpoint | +| `Authentication.ServicePulse.ClientId` | string | - | OAuth client ID for ServicePulse | +| `Authentication.ServicePulse.Authority` | string | - | Authority URL for ServicePulse (defaults to main Authority) | +| `Authentication.ServicePulse.ApiScopes` | string | - | API scopes for ServicePulse to request | + +### HTTPS Settings + +| Setting | Type | Default | Description | +|---------|------|---------|-------------| +| `Https.Enabled` | bool | `false` | Enable Kestrel HTTPS with certificate | +| `Https.CertificatePath` | string | - | Path to PFX/PEM certificate file | +| `Https.CertificatePassword` | string | - | Certificate password (if required) | +| `Https.RedirectHttpToHttps` | bool | `false` | Redirect HTTP requests to HTTPS | +| `Https.EnableHsts` | bool | `false` | Enable HTTP Strict Transport Security | +| `Https.HstsMaxAgeSeconds` | int | `31536000` | HSTS max-age in seconds (1 year) | +| `Https.HstsIncludeSubDomains` | bool | `false` | Include subdomains in HSTS | + +### Forwarded Headers Settings + +| Setting | Type | Default | Description | +|---------|------|---------|-------------| +| `ForwardedHeaders.Enabled` | bool | `true` | Enable forwarded headers processing | +| `ForwardedHeaders.TrustAllProxies` | bool | `true` | Trust X-Forwarded-* from any source | +| `ForwardedHeaders.KnownProxies` | string | - | Comma-separated list of trusted proxy IPs | +| `ForwardedHeaders.KnownNetworks` | string | - | Comma-separated list of trusted CIDR networks | + +> **Note:** If `KnownProxies` or `KnownNetworks` are configured, `TrustAllProxies` is automatically set to `false`. + +### CORS Settings + +| Setting | Type | Default | Description | +|---------|------|---------|-------------| +| `Cors.AllowAnyOrigin` | bool | `true` | Allow requests from any origin | +| `Cors.AllowedOrigins` | string | - | Comma-separated list of allowed origins | + +> **Note:** If `AllowedOrigins` is configured, `AllowAnyOrigin` is automatically set to `false`. + +--- + +## Scenario Comparison Matrix + +| Feature | Default | Reverse Proxy (SSL Termination) | Direct HTTPS | Reverse Proxy + Auth | Direct HTTPS + Auth | End-to-End Encryption + Auth | +|---------|:-------:|:-------------------------------:|:------------:|:--------------------:|:-------------------:|:----------------------------:| +| **JWT Authentication** | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ | +| **Direct (Kestrel) HTTPS** | ❌ | ❌ | ✅ | ❌ | ✅ | ✅ | +| **HTTPS Redirection** | ❌ | ❌ | ✅ | ❌ | ✅ | ❌ (Handled by Reverse Proxy) | +| **HSTS** | ❌ | ❌ | ✅ | ❌ | ✅ | ❌ (Handled by Reverse Proxy) | +| **Restricted CORS** | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | +| **Forwarded Headers** | ✅ | ✅ | ❌ | ✅ | ❌ (Not needed. No Reverse Proxy) | ✅ | +| **Restricted Proxy Trust** | ❌ | ✅ | N/A | ✅ | N/A | ✅ | +| | | | | | | | +| **Reverse Proxy** | Optional | Yes | No | Yes | No | Yes | +| **Internal Traffic Encrypted** | ❌ | ❌ | ✅ | ❌ | ✅ | ✅ | + +**Legend:** + +- ✅ = Enabled +- ❌ = Disabled +- N/A = Not Applicable diff --git a/docs/local-https-testing.md b/docs/local-https-testing.md new file mode 100644 index 0000000000..00150722c3 --- /dev/null +++ b/docs/local-https-testing.md @@ -0,0 +1,196 @@ +# Local Testing with Direct HTTPS + +This guide explains how to test ServiceControl with direct HTTPS enabled on Kestrel, without using a reverse proxy. This is useful for testing scenarios like: + +- Direct TLS termination at ServiceControl +- HTTPS redirection +- HSTS (HTTP Strict Transport Security) +- End-to-end encryption testing + +## Prerequisites + +- [mkcert](https://github.com/FiloSottile/mkcert) for generating local development certificates +- ServiceControl built locally (see main README for build instructions) + +### Installing mkcert + +**Windows (using Chocolatey):** + +```powershell +choco install mkcert +``` + +**Windows (using Scoop):** + +```powershell +scoop install mkcert +``` + +**macOS (using Homebrew):** + +```bash +brew install mkcert +``` + +**Linux:** + +```bash +# Debian/Ubuntu +sudo apt install libnss3-tools +# Then download from https://github.com/FiloSottile/mkcert/releases + +# Arch Linux +sudo pacman -S mkcert +``` + +After installing, run `mkcert -install` to install the local CA in your system trust store. + +## Step 1: Create the Local Development Folder + +Create a `.local` folder in the repository root (this folder is gitignored): + +```bash +mkdir .local +mkdir .local/certs +``` + +## Step 2: Generate PFX Certificates + +Kestrel requires certificates in PFX format. Use mkcert to generate them: + +```bash +# Install mkcert's root CA (one-time setup) +mkcert -install + +# Navigate to the certs folder +cd .local/certs + +# Generate PFX certificate for localhost +mkcert -p12-file localhost.pfx -pkcs12 localhost 127.0.0.1 ::1 +``` + +When prompted for a password, you can use an empty password by pressing Enter, or set a password and note it for the configuration step. + +## Step 3: Configure ServiceControl Instances + +Configure HTTPS in the `App.config` file for each ServiceControl instance. See [HTTPS Settings](hosting-guide.md#https-settings) in the Hosting Guide for all available options. + +| Instance | Config Key Prefix | App.config Location | +|----------|-------------------|---------------------| +| ServiceControl (Primary) | `ServiceControl/` | `src/ServiceControl/App.config` | +| ServiceControl.Audit | `ServiceControl.Audit/` | `src/ServiceControl.Audit/App.config` | +| ServiceControl.Monitoring | `Monitoring/` | `src/ServiceControl.Monitoring/App.config` | + +Example for ServiceControl (Primary): + +```xml + + + + + + + + + + + + + + + +``` + +> **Note:** Replace `C:\path\to\repo` with the actual path to your ServiceControl repository. Use the full absolute path to the PFX file. + +## Step 4: Start ServiceControl Instances + +Start the ServiceControl instances locally using your preferred method: + +### **Option A: Visual Studio** + +1. Open `src/ServiceControl.sln` +2. Run the desired project(s) with the appropriate launch profile + +### **Option B: Command Line** + +```bash +# Run ServiceControl (Primary) +dotnet run --project src/ServiceControl/ServiceControl.csproj + +# Run ServiceControl.Audit +dotnet run --project src/ServiceControl.Audit/ServiceControl.Audit.csproj + +# Run ServiceControl.Monitoring +dotnet run --project src/ServiceControl.Monitoring/ServiceControl.Monitoring.csproj +``` + +## Step 5: Verify the Setup + +Test that HTTPS is working correctly: + +```bash +# Test ServiceControl (Primary) +curl https://localhost:33333/api + +# Test ServiceControl.Audit +curl https://localhost:44444/api + +# Test ServiceControl.Monitoring +curl https://localhost:33633/api +``` + +If you've installed mkcert's root CA, the requests should succeed without certificate warnings. + +### Testing HTTPS Redirection + +If `RedirectHttpToHttps` is enabled, HTTP requests should redirect to HTTPS: + +```bash +# This should redirect to https://localhost:33333/api +curl -v http://localhost:33333/api +``` + +### Testing HSTS + +If `EnableHsts` is enabled, the response should include the `Strict-Transport-Security` header: + +```bash +curl -v https://localhost:33333/api 2>&1 | grep -i strict-transport-security +``` + +## HTTPS Configuration Reference + +| Setting | Default | Description | +|---------|---------|-------------| +| `Https.Enabled` | `false` | Enable Kestrel HTTPS | +| `Https.CertificatePath` | - | Path to PFX certificate file | +| `Https.CertificatePassword` | - | Certificate password (empty string for no password) | +| `Https.RedirectHttpToHttps` | `false` | Redirect HTTP requests to HTTPS | +| `Https.EnableHsts` | `false` | Enable HTTP Strict Transport Security | +| `Https.HstsMaxAgeSeconds` | `31536000` | HSTS max-age (1 year) | +| `Https.HstsIncludeSubDomains` | `false` | Include subdomains in HSTS | + +## Troubleshooting + +### Certificate not found + +Ensure the `CertificatePath` is an absolute path and the file exists. + +### Certificate password incorrect + +If you set a password when generating the PFX, ensure it matches `CertificatePassword` in the config. + +### Certificate errors in browser + +1. Ensure mkcert's root CA is installed: `mkcert -install` +2. Restart your browser after installing the root CA + +### Port already in use + +Ensure no other process is using the ServiceControl ports (33333, 44444, 33633). + +## See Also + +- [Hosting Guide](hosting-guide.md) - Detailed configuration reference for all deployment scenarios +- [Local NGINX Testing](local-nginx-testing.md) - Testing with a reverse proxy diff --git a/docs/local-nginx-testing.md b/docs/local-nginx-testing.md new file mode 100644 index 0000000000..2703206ac8 --- /dev/null +++ b/docs/local-nginx-testing.md @@ -0,0 +1,345 @@ +# Local Testing with NGINX Reverse Proxy + +This guide explains how to set up a local development environment with NGINX as a reverse proxy in front of ServiceControl instances. This is useful for testing scenarios like: + +- SSL/TLS termination at the reverse proxy +- Forwarded headers handling (`X-Forwarded-For`, `X-Forwarded-Proto`, `X-Forwarded-Host`) +- Testing CORS configuration +- Simulating production deployment topology + +## Prerequisites + +- [Docker Desktop](https://www.docker.com/products/docker-desktop/) installed and running +- [mkcert](https://github.com/FiloSottile/mkcert) for generating local development certificates +- ServiceControl built locally (see main README for build instructions) + +### Installing mkcert + +**Windows (using Chocolatey):** + +```powershell +choco install mkcert +``` + +**Windows (using Scoop):** + +```powershell +scoop install mkcert +``` + +**macOS (using Homebrew):** + +```bash +brew install mkcert +``` + +**Linux:** + +```bash +# Debian/Ubuntu +sudo apt install libnss3-tools +# Then download from https://github.com/FiloSottile/mkcert/releases + +# Arch Linux +sudo pacman -S mkcert +``` + +After installing, run `mkcert -install` to install the local CA in your system trust store. + +## Step 1: Create the Local Development Folder + +Create a `.local` folder in the repository root (this folder is gitignored): + +```bash +mkdir .local +mkdir .local/certs +``` + +## Step 2: Generate SSL Certificates + +Use mkcert to generate trusted local development certificates: + +```bash +# Install mkcert's root CA (one-time setup) +mkcert -install + +# Navigate to the certs folder +cd .local/certs + +# Generate certificates for all ServiceControl hostnames +mkcert -cert-file local-platform.pem -key-file local-platform-key.pem \ + servicecontrol.localhost \ + servicecontrol-audit.localhost \ + servicecontrol-monitor.localhost \ + localhost +``` + +## Step 3: Create Docker Compose Configuration + +Create `.local/compose.yml`: + +```yaml +name: service-platform + +services: + reverse-proxy: + image: nginx:alpine + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx.conf:/etc/nginx/nginx.conf:ro + - ./certs/local-platform.pem:/etc/nginx/certs/local.pem:ro + - ./certs/local-platform-key.pem:/etc/nginx/certs/local-key.pem:ro +``` + +## Step 4: Create NGINX Configuration + +Create `.local/nginx.conf`: + +```nginx +events { worker_connections 1024; } + +http { + # WebSocket support: set connection to 'upgrade' if Upgrade header present + map $http_upgrade $connection_upgrade { + default upgrade; + '' close; + } + + # Shared SSL Settings + ssl_certificate /etc/nginx/certs/local.pem; + ssl_certificate_key /etc/nginx/certs/local-key.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + + # ServiceControl (Primary) + server { + listen 443 ssl; + server_name servicecontrol.localhost; + + location / { + proxy_pass http://host.docker.internal:33333; + + # WebSocket Support + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + + # Forwarded Headers + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + } + + # ServiceControl.Audit + server { + listen 443 ssl; + server_name servicecontrol-audit.localhost; + + location / { + proxy_pass http://host.docker.internal:44444; + + # WebSocket Support + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + + # Forwarded Headers + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + } + + # ServiceControl.Monitoring + server { + listen 443 ssl; + server_name servicecontrol-monitor.localhost; + + location / { + proxy_pass http://host.docker.internal:33633; + + # WebSocket Support + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + + # Forwarded Headers + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + } +} +``` + +## Step 5: Configure Hosts File + +Add the following entries to your hosts file: + +**Windows:** `C:\Windows\System32\drivers\etc\hosts` +**Linux/macOS:** `/etc/hosts` + +```text +127.0.0.1 servicecontrol.localhost +127.0.0.1 servicecontrol-audit.localhost +127.0.0.1 servicecontrol-monitor.localhost +``` + +## Step 6: Configure ServiceControl Instances + +Configure forwarded headers in the `App.config` file for each ServiceControl instance. See [Forwarded Headers Settings](hosting-guide.md#forwarded-headers-settings) in the Hosting Guide for all available options. + +For local testing with this NGINX setup, set `KnownProxies` to `127.0.0.1`: + +| Instance | Config Key Prefix | App.config Location | +|----------|-------------------|---------------------| +| ServiceControl (Primary) | `ServiceControl/` | `src/ServiceControl/App.config` | +| ServiceControl.Audit | `ServiceControl.Audit/` | `src/ServiceControl.Audit/App.config` | +| ServiceControl.Monitoring | `Monitoring/` | `src/ServiceControl.Monitoring/App.config` | + +Example for ServiceControl (Primary): + +```xml + + + + +``` + +> **Note:** The `KnownProxies` value is `127.0.0.1` because NGINX running in Docker connects to the host via `host.docker.internal`, which resolves to `127.0.0.1` on the host machine. + +## Step 7: Start the NGINX Reverse Proxy + +From the repository root: + +```bash +docker compose -f .local/compose.yml up -d +``` + +This starts an NGINX container that: + +- Listens on ports 80 (HTTP) and 443 (HTTPS) +- Terminates SSL/TLS using the mkcert certificates +- Proxies requests to ServiceControl instances running on the host + +## Step 8: Start ServiceControl Instances + +Start the ServiceControl instances locally using your preferred method: + +### **Option A: Visual Studio** + +1. Open `src/ServiceControl.sln` +2. Run the desired project(s) with the appropriate launch profile + +### **Option B: Command Line** + +```bash +# Run ServiceControl (Primary) +dotnet run --project src/ServiceControl/ServiceControl.csproj + +# Run ServiceControl.Audit +dotnet run --project src/ServiceControl.Audit/ServiceControl.Audit.csproj + +# Run ServiceControl.Monitoring +dotnet run --project src/ServiceControl.Monitoring/ServiceControl.Monitoring.csproj +``` + +## Step 9: Verify the Setup + +Test that the reverse proxy is working correctly: + +```bash +# Test ServiceControl (Primary) +curl -k https://servicecontrol.localhost/api + +# Test ServiceControl.Audit +curl -k https://servicecontrol-audit.localhost/api + +# Test ServiceControl.Monitoring +curl -k https://servicecontrol-monitor.localhost/api +``` + +The `-k` flag is used to accept the self-signed certificate. If you've installed mkcert's root CA, you can omit it. + +## Final Directory Structure + +After completing the setup, your `.local` folder should look like: + +```text +.local/ +├── compose.yml +├── nginx.conf +└── certs/ + ├── local-platform.pem + └── local-platform-key.pem +``` + +## NGINX Configuration Reference + +| Server Name | HTTPS Port | Backend Port | Instance | +|------------|------------|--------------|----------| +| `servicecontrol.localhost` | 443 | 33333 | ServiceControl (Primary) | +| `servicecontrol-audit.localhost` | 443 | 44444 | ServiceControl.Audit | +| `servicecontrol-monitor.localhost` | 443 | 33633 | ServiceControl.Monitoring | + +Each server block: + +- Terminates SSL/TLS +- Sets forwarded headers (`X-Forwarded-For`, `X-Forwarded-Proto`, `X-Forwarded-Host`) +- Supports WebSocket connections (for SignalR) +- Proxies to `host.docker.internal` to reach the host machine + +## Forwarded Headers Behavior + +When `ForwardedHeaders.KnownProxies` is configured correctly: + +- `Request.Scheme` will be `https` (from `X-Forwarded-Proto`) +- `Request.Host` will be the external hostname (from `X-Forwarded-Host`) +- Client IP will be available from `X-Forwarded-For` + +When the proxy is **not** trusted (incorrect `KnownProxies`): + +- `X-Forwarded-*` headers are **ignored** (not applied to the request) +- `Request.Scheme` remains `http` +- `Request.Host` remains the internal hostname +- The request is still processed (not blocked) + +## Troubleshooting + +### "Connection refused" errors + +Ensure the ServiceControl instances are running and listening on the expected ports. + +### Headers not being applied + +1. Verify `ForwardedHeaders.Enabled` is `true` +2. Check that `KnownProxies` includes `127.0.0.1` +3. Review the ServiceControl logs for forwarded headers configuration messages + +### Certificate errors in browser + +1. Ensure mkcert's root CA is installed: `mkcert -install` +2. Restart your browser after installing the root CA + +### Docker networking issues + +If using Docker Desktop on Windows with WSL2: + +- Ensure `host.docker.internal` resolves correctly +- Check that the ServiceControl ports are not blocked by Windows Firewall + +## Stopping the Environment + +```bash +docker compose -f .local/compose.yml down +``` + +## See Also + +- [Hosting Guide](hosting-guide.md) - Detailed configuration reference for all deployment scenarios diff --git a/src/ServiceControl.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs b/src/ServiceControl.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs index 8ac0a4454a..ff32483f1f 100644 --- a/src/ServiceControl.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs +++ b/src/ServiceControl.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs @@ -120,14 +120,14 @@ async Task InitializeServiceControl(ScenarioContext context) EnvironmentName = Environments.Development }); hostBuilder.AddServiceControl(settings, configuration); - hostBuilder.AddServiceControlApi(); + hostBuilder.AddServiceControlApi(settings.CorsSettings); hostBuilder.AddServiceControlTesting(settings); hostBuilderCustomization(hostBuilder); host = hostBuilder.Build(); - host.UseServiceControl(); + host.UseServiceControl(settings.ForwardedHeadersSettings, settings.HttpsSettings); await host.StartAsync(); DomainEvents = host.Services.GetRequiredService(); // Bring this back and look into the base address of the client diff --git a/src/ServiceControl.Audit.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs b/src/ServiceControl.Audit.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs index e0b41effe6..b2a1bfbbbb 100644 --- a/src/ServiceControl.Audit.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs +++ b/src/ServiceControl.Audit.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs @@ -129,14 +129,14 @@ async Task InitializeServiceControl(ScenarioContext context) return criticalErrorContext.Stop(cancellationToken); }, settings, configuration); - hostBuilder.AddServiceControlAuditApi(); + hostBuilder.AddServiceControlAuditApi(settings.CorsSettings); hostBuilder.AddServiceControlAuditTesting(settings); hostBuilderCustomization(hostBuilder); host = hostBuilder.Build(); - host.UseServiceControlAudit(); + host.UseServiceControlAudit(settings.ForwardedHeadersSettings, settings.HttpsSettings); await host.StartAsync(); ServiceProvider = host.Services; InstanceTestServer = host.GetTestServer(); diff --git a/src/ServiceControl.Audit.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt b/src/ServiceControl.Audit.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt index 7113c06339..b27513bdb0 100644 --- a/src/ServiceControl.Audit.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt +++ b/src/ServiceControl.Audit.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt @@ -3,6 +3,38 @@ "LogLevel": "Information", "LogPath": "C:\\Logs" }, + "OpenIdConnectSettings": { + "enabled": false, + "authority": null, + "audience": null, + "validateIssuer": false, + "validateAudience": false, + "validateLifetime": false, + "validateIssuerSigningKey": false, + "requireHttpsMetadata": false, + "servicePulseAuthority": null, + "servicePulseClientId": null, + "servicePulseApiScopes": null + }, + "ForwardedHeadersSettings": { + "Enabled": true, + "TrustAllProxies": true, + "KnownProxiesRaw": [], + "KnownNetworks": [] + }, + "HttpsSettings": { + "Enabled": false, + "CertificatePath": null, + "CertificatePassword": null, + "RedirectHttpToHttps": false, + "EnableHsts": false, + "HstsMaxAgeSeconds": 31536000, + "HstsIncludeSubDomains": false + }, + "CorsSettings": { + "AllowAnyOrigin": true, + "AllowedOrigins": [] + }, "MessageFilter": null, "ValidateConfiguration": true, "RootUrl": "http://localhost:8888/", diff --git a/src/ServiceControl.Audit/App.config b/src/ServiceControl.Audit/App.config index 22a191271b..6fc8a846e7 100644 --- a/src/ServiceControl.Audit/App.config +++ b/src/ServiceControl.Audit/App.config @@ -28,21 +28,42 @@ These settings are only here so that we can debug ServiceControl while developin - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/ServiceControl.Audit/Infrastructure/Hosting/Commands/RunCommand.cs b/src/ServiceControl.Audit/Infrastructure/Hosting/Commands/RunCommand.cs index 07dc7f7d4a..728a1b9a98 100644 --- a/src/ServiceControl.Audit/Infrastructure/Hosting/Commands/RunCommand.cs +++ b/src/ServiceControl.Audit/Infrastructure/Hosting/Commands/RunCommand.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Builder; using NServiceBus; using ServiceControl.Hosting.Auth; + using ServiceControl.Hosting.Https; using Settings; using WebApi; @@ -18,15 +19,16 @@ public override async Task Execute(HostArguments args, Settings settings) var hostBuilder = WebApplication.CreateBuilder(); hostBuilder.AddServiceControlAuthentication(settings.OpenIdConnectSettings); + hostBuilder.AddServiceControlHttps(settings.HttpsSettings); hostBuilder.AddServiceControlAudit((_, __) => { //Do nothing. The transports in NSB 8 are designed to handle broker outages. Audit ingestion will be paused when broker is unavailable. return Task.CompletedTask; }, settings, endpointConfiguration); - hostBuilder.AddServiceControlAuditApi(); + hostBuilder.AddServiceControlAuditApi(settings.CorsSettings); var app = hostBuilder.Build(); - app.UseServiceControlAudit(); + app.UseServiceControlAudit(settings.ForwardedHeadersSettings, settings.HttpsSettings); app.UseServiceControlAuthentication(authenticationEnabled: settings.OpenIdConnectSettings.Enabled); await app.RunAsync(settings.RootUrl); diff --git a/src/ServiceControl.Audit/Infrastructure/Settings/Settings.cs b/src/ServiceControl.Audit/Infrastructure/Settings/Settings.cs index c6c9ac5a57..8f085fa2a0 100644 --- a/src/ServiceControl.Audit/Infrastructure/Settings/Settings.cs +++ b/src/ServiceControl.Audit/Infrastructure/Settings/Settings.cs @@ -17,7 +17,10 @@ public Settings(string transportType = null, string persisterType = null, Loggin { LoggingSettings = loggingSettings ?? new(SettingsRootNamespace); - OpenIdConnectSettings = new OpenIdConnectSettings(SettingsRootNamespace, ValidateConfiguration); + OpenIdConnectSettings = new OpenIdConnectSettings(SettingsRootNamespace, ValidateConfiguration, requireServicePulseSettings: false); + ForwardedHeadersSettings = new ForwardedHeadersSettings(SettingsRootNamespace); + HttpsSettings = new HttpsSettings(SettingsRootNamespace); + CorsSettings = new CorsSettings(SettingsRootNamespace); // Overwrite the instance name if it is specified in ENVVAR, reg, or config file -- LEGACY SETTING NAME InstanceName = SettingsReader.Read(SettingsRootNamespace, "InternalQueueName", InstanceName); @@ -96,6 +99,12 @@ void LoadAuditQueueInformation() public OpenIdConnectSettings OpenIdConnectSettings { get; } + public ForwardedHeadersSettings ForwardedHeadersSettings { get; } + + public HttpsSettings HttpsSettings { get; } + + public CorsSettings CorsSettings { get; } + //HINT: acceptance tests only public Func MessageFilter { get; set; } @@ -112,7 +121,8 @@ public string RootUrl suffix = $"{VirtualDirectory}/"; } - return $"http://{Hostname}:{Port}/{suffix}"; + var scheme = HttpsSettings.Enabled ? "https" : "http"; + return $"{scheme}://{Hostname}:{Port}/{suffix}"; } } diff --git a/src/ServiceControl.Audit/Infrastructure/WebApi/Cors.cs b/src/ServiceControl.Audit/Infrastructure/WebApi/Cors.cs index c5b024930d..e0ac607f57 100644 --- a/src/ServiceControl.Audit/Infrastructure/WebApi/Cors.cs +++ b/src/ServiceControl.Audit/Infrastructure/WebApi/Cors.cs @@ -1,16 +1,26 @@ namespace ServiceControl.Audit.Infrastructure.WebApi { using Microsoft.AspNetCore.Cors.Infrastructure; + using ServiceControl.Infrastructure; static class Cors { - public static CorsPolicy GetDefaultPolicy() + public static CorsPolicy GetDefaultPolicy(CorsSettings settings) { var builder = new CorsPolicyBuilder(); - builder.AllowAnyOrigin(); + if (settings.AllowAnyOrigin) + { + builder.AllowAnyOrigin(); + } + else if (settings.AllowedOrigins.Count > 0) + { + builder.WithOrigins([.. settings.AllowedOrigins]); + builder.AllowCredentials(); + } + builder.WithExposedHeaders(["ETag", "Last-Modified", "Link", "Total-Count", "X-Particular-Version"]); - builder.WithHeaders(["Origin", "X-Requested-With", "Content-Type", "Accept"]); + builder.WithHeaders(["Origin", "X-Requested-With", "Content-Type", "Accept", "Authorization"]); builder.WithMethods(["POST", "GET", "PUT", "DELETE", "OPTIONS", "PATCH"]); return builder.Build(); diff --git a/src/ServiceControl.Audit/Infrastructure/WebApi/HostApplicationBuilderExtensions.cs b/src/ServiceControl.Audit/Infrastructure/WebApi/HostApplicationBuilderExtensions.cs index 4eaa203c64..638041d4b1 100644 --- a/src/ServiceControl.Audit/Infrastructure/WebApi/HostApplicationBuilderExtensions.cs +++ b/src/ServiceControl.Audit/Infrastructure/WebApi/HostApplicationBuilderExtensions.cs @@ -4,12 +4,13 @@ using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; + using ServiceControl.Infrastructure; static class HostApplicationBuilderExtensions { - public static void AddServiceControlAuditApi(this IHostApplicationBuilder builder) + public static void AddServiceControlAuditApi(this IHostApplicationBuilder builder, CorsSettings corsSettings) { - builder.Services.AddCors(options => options.AddDefaultPolicy(Cors.GetDefaultPolicy())); + builder.Services.AddCors(options => options.AddDefaultPolicy(Cors.GetDefaultPolicy(corsSettings))); // We're not explicitly adding Gzip here because it's already in the default list of supported compressors builder.Services.AddResponseCompression(); diff --git a/src/ServiceControl.Audit/Properties/launchSettings.json b/src/ServiceControl.Audit/Properties/launchSettings.json index 8600c4f462..127d979906 100644 --- a/src/ServiceControl.Audit/Properties/launchSettings.json +++ b/src/ServiceControl.Audit/Properties/launchSettings.json @@ -3,6 +3,7 @@ "ServiceControl.Audit": { "commandName": "Project", "launchBrowser": false, + "applicationUrl": "http://0.0.0.0:44444", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/src/ServiceControl.Audit/WebApplicationExtensions.cs b/src/ServiceControl.Audit/WebApplicationExtensions.cs index e12b2a8465..76785dd77d 100644 --- a/src/ServiceControl.Audit/WebApplicationExtensions.cs +++ b/src/ServiceControl.Audit/WebApplicationExtensions.cs @@ -2,13 +2,16 @@ namespace ServiceControl.Audit; using Infrastructure.WebApi; using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.HttpOverrides; +using ServiceControl.Hosting.ForwardedHeaders; +using ServiceControl.Hosting.Https; +using ServiceControl.Infrastructure; public static class WebApplicationExtensions { - public static void UseServiceControlAudit(this WebApplication app) + public static void UseServiceControlAudit(this WebApplication app, ForwardedHeadersSettings forwardedHeadersSettings, HttpsSettings httpsSettings) { - app.UseForwardedHeaders(new ForwardedHeadersOptions { ForwardedHeaders = ForwardedHeaders.All }); + app.UseServiceControlForwardedHeaders(forwardedHeadersSettings); + app.UseServiceControlHttps(httpsSettings); app.UseResponseCompression(); app.UseMiddleware(); app.UseHttpLogging(); diff --git a/src/ServiceControl.Hosting/Auth/HostApplicationBuilderExtensions.cs b/src/ServiceControl.Hosting/Auth/HostApplicationBuilderExtensions.cs index a4b66f9fea..e6e542b306 100644 --- a/src/ServiceControl.Hosting/Auth/HostApplicationBuilderExtensions.cs +++ b/src/ServiceControl.Hosting/Auth/HostApplicationBuilderExtensions.cs @@ -1,8 +1,13 @@ namespace ServiceControl.Hosting.Auth { using System; + using System.Text.Json; + using System.Threading.Tasks; + using Microsoft.AspNetCore.Authentication.JwtBearer; + using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; + using Microsoft.IdentityModel.Tokens; using ServiceControl.Infrastructure; public static class HostApplicationBuilderExtensions @@ -23,7 +28,7 @@ public static void AddServiceControlAuthentication(this IHostApplicationBuilder { options.Authority = oidcSettings.Authority; // Configure token validation parameters - options.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters + options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = oidcSettings.ValidateIssuer, ValidateAudience = oidcSettings.ValidateAudience, @@ -35,7 +40,100 @@ public static void AddServiceControlAuthentication(this IHostApplicationBuilder options.RequireHttpsMetadata = oidcSettings.RequireHttpsMetadata; // Don't map inbound claims to legacy Microsoft claim types options.MapInboundClaims = false; + + // Custom error response handling for better client experience + options.Events = new JwtBearerEvents + { + OnAuthenticationFailed = context => + { + if (context.Exception is SecurityTokenExpiredException) + { + context.Response.Headers.Append("X-Token-Expired", "true"); + } + return Task.CompletedTask; + }, + OnChallenge = context => + { + // Skip if response already started or already handled + if (context.Response.HasStarted || context.Handled) + { + return Task.CompletedTask; + } + + context.HandleResponse(); + context.Response.StatusCode = 401; + context.Response.ContentType = "application/json"; + + var errorResponse = new AuthErrorResponse + { + Error = "unauthorized", + Message = GetErrorMessage(context) + }; + + return context.Response.WriteAsync(JsonSerializer.Serialize(errorResponse, JsonSerializerOptions)); + }, + OnForbidden = context => + { + // Skip if response already started + if (context.Response.HasStarted) + { + return Task.CompletedTask; + } + + context.Response.StatusCode = 403; + context.Response.ContentType = "application/json"; + + var errorResponse = new AuthErrorResponse + { + Error = "forbidden", + Message = "You do not have permission to access this resource." + }; + + return context.Response.WriteAsync(JsonSerializer.Serialize(errorResponse, JsonSerializerOptions)); + } + }; }); } + + static string GetErrorMessage(JwtBearerChallengeContext context) + { + if (context.AuthenticateFailure is SecurityTokenExpiredException) + { + return "The token has expired. Please obtain a new token and retry."; + } + + if (context.AuthenticateFailure is SecurityTokenInvalidSignatureException) + { + return "The token signature is invalid."; + } + + if (context.AuthenticateFailure is SecurityTokenInvalidAudienceException) + { + return "The token audience is invalid."; + } + + if (context.AuthenticateFailure is SecurityTokenInvalidIssuerException) + { + return "The token issuer is invalid."; + } + + if (context.AuthenticateFailure != null) + { + return "The token is invalid."; + } + + return "Authentication required. Please provide a valid Bearer token."; + } + + static readonly JsonSerializerOptions JsonSerializerOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + } + + class AuthErrorResponse + { + public string Error { get; set; } + public string Message { get; set; } } } diff --git a/src/ServiceControl.Hosting/ForwardedHeaders/WebApplicationExtensions.cs b/src/ServiceControl.Hosting/ForwardedHeaders/WebApplicationExtensions.cs new file mode 100644 index 0000000000..284d3f9add --- /dev/null +++ b/src/ServiceControl.Hosting/ForwardedHeaders/WebApplicationExtensions.cs @@ -0,0 +1,39 @@ +namespace ServiceControl.Hosting.ForwardedHeaders; + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.HttpOverrides; +using ServiceControl.Infrastructure; + +public static class WebApplicationExtensions +{ + public static void UseServiceControlForwardedHeaders(this WebApplication app, ForwardedHeadersSettings settings) + { + if (!settings.Enabled) + { + return; + } + + var options = new ForwardedHeadersOptions + { + ForwardedHeaders = ForwardedHeaders.All + }; + + if (!settings.TrustAllProxies) + { + options.KnownProxies.Clear(); + options.KnownNetworks.Clear(); + + foreach (var proxy in settings.KnownProxies) + { + options.KnownProxies.Add(proxy); + } + + foreach (var network in settings.KnownNetworks) + { + options.KnownNetworks.Add(IPNetwork.Parse(network)); + } + } + + app.UseForwardedHeaders(options); + } +} diff --git a/src/ServiceControl.Hosting/Https/HostApplicationBuilderExtensions.cs b/src/ServiceControl.Hosting/Https/HostApplicationBuilderExtensions.cs new file mode 100644 index 0000000000..0a89c006d6 --- /dev/null +++ b/src/ServiceControl.Hosting/Https/HostApplicationBuilderExtensions.cs @@ -0,0 +1,45 @@ +namespace ServiceControl.Hosting.Https; + +using System; +using System.Security.Cryptography.X509Certificates; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.HttpsPolicy; +using Microsoft.Extensions.DependencyInjection; +using ServiceControl.Infrastructure; + +public static class HostApplicationBuilderExtensions +{ + public static void AddServiceControlHttps(this WebApplicationBuilder hostBuilder, HttpsSettings settings) + { + if (settings.EnableHsts) + { + hostBuilder.Services.Configure(options => + { + options.MaxAge = TimeSpan.FromSeconds(settings.HstsMaxAgeSeconds); + options.IncludeSubDomains = settings.HstsIncludeSubDomains; + }); + } + + if (settings.Enabled) + { + hostBuilder.WebHost.ConfigureKestrel(kestrel => + { + kestrel.ConfigureHttpsDefaults(httpsOptions => + { + httpsOptions.ServerCertificate = LoadCertificate(settings); + }); + }); + } + } + + static X509Certificate2 LoadCertificate(HttpsSettings settings) + { + if (string.IsNullOrEmpty(settings.CertificatePassword)) + { + return new X509Certificate2(settings.CertificatePath); + } + + return new X509Certificate2(settings.CertificatePath, settings.CertificatePassword); + } +} diff --git a/src/ServiceControl.Hosting/Https/WebApplicationExtensions.cs b/src/ServiceControl.Hosting/Https/WebApplicationExtensions.cs new file mode 100644 index 0000000000..1123a9b3a8 --- /dev/null +++ b/src/ServiceControl.Hosting/Https/WebApplicationExtensions.cs @@ -0,0 +1,21 @@ +namespace ServiceControl.Hosting.Https; + +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Hosting; +using ServiceControl.Infrastructure; + +public static class WebApplicationExtensions +{ + public static void UseServiceControlHttps(this WebApplication app, HttpsSettings settings) + { + if (settings.EnableHsts && !app.Environment.IsDevelopment()) + { + app.UseHsts(); + } + + if (settings.RedirectHttpToHttps) + { + app.UseHttpsRedirection(); + } + } +} diff --git a/src/ServiceControl.Infrastructure/CorsSettings.cs b/src/ServiceControl.Infrastructure/CorsSettings.cs new file mode 100644 index 0000000000..44718b8050 --- /dev/null +++ b/src/ServiceControl.Infrastructure/CorsSettings.cs @@ -0,0 +1,80 @@ +namespace ServiceControl.Infrastructure; + +using System; +using System.Collections.Generic; +using Microsoft.Extensions.Logging; +using ServiceControl.Configuration; + +public class CorsSettings +{ + readonly ILogger logger = LoggerUtil.CreateStaticLogger(); + + public CorsSettings(SettingsRootNamespace rootNamespace) + { + // Default to allowing any origin for backwards compatibility + AllowAnyOrigin = SettingsReader.Read(rootNamespace, "Cors.AllowAnyOrigin", true); + + var allowedOriginsValue = SettingsReader.Read(rootNamespace, "Cors.AllowedOrigins"); + if (!string.IsNullOrWhiteSpace(allowedOriginsValue)) + { + AllowedOrigins = ParseOrigins(allowedOriginsValue); + + // If specific origins are configured, disable AllowAnyOrigin + if (AllowedOrigins.Count > 0 && AllowAnyOrigin) + { + logger.LogInformation("Cors.AllowedOrigins configured, setting AllowAnyOrigin to false"); + AllowAnyOrigin = false; + } + } + + LogConfiguration(); + } + + /// + /// When true, allows requests from any origin. Default is true for backwards compatibility. + /// + public bool AllowAnyOrigin { get; private set; } + + /// + /// List of specific origins to allow when AllowAnyOrigin is false. + /// + public List AllowedOrigins { get; } = []; + + List ParseOrigins(string value) + { + var origins = new List(); + var parts = value.Split([',', ';'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + foreach (var part in parts) + { + if (Uri.TryCreate(part, UriKind.Absolute, out var uri)) + { + // Normalize: use origin format (scheme://host:port) + var origin = $"{uri.Scheme}://{uri.Authority}"; + origins.Add(origin); + } + else + { + logger.LogWarning("Invalid origin URL in Cors.AllowedOrigins: '{InvalidOrigin}'", part); + } + } + + return origins; + } + + void LogConfiguration() + { + logger.LogInformation("CORS configuration:"); + logger.LogInformation(" AllowAnyOrigin: {AllowAnyOrigin}", AllowAnyOrigin); + + if (AllowedOrigins.Count > 0) + { + logger.LogInformation(" AllowedOrigins: {AllowedOrigins}", string.Join(", ", AllowedOrigins)); + } + + if (AllowAnyOrigin) + { + logger.LogWarning("Cors.AllowAnyOrigin is true. Any website can make requests to this API. Consider configuring Cors.AllowedOrigins for production environments."); + } + } +} diff --git a/src/ServiceControl.Infrastructure/ForwardedHeadersSettings.cs b/src/ServiceControl.Infrastructure/ForwardedHeadersSettings.cs new file mode 100644 index 0000000000..a27c0c7e76 --- /dev/null +++ b/src/ServiceControl.Infrastructure/ForwardedHeadersSettings.cs @@ -0,0 +1,142 @@ +namespace ServiceControl.Infrastructure; + +using System; +using System.Collections.Generic; +using System.Net; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; +using ServiceControl.Configuration; + +public class ForwardedHeadersSettings +{ + readonly ILogger logger = LoggerUtil.CreateStaticLogger(); + + public ForwardedHeadersSettings(SettingsRootNamespace rootNamespace) + { + Enabled = SettingsReader.Read(rootNamespace, "ForwardedHeaders.Enabled", true); + + if (!Enabled) + { + return; + } + + // Default to trusting all proxies for backwards compatibility + // Customers can set this to false and configure KnownProxies/KnownNetworks for better security + TrustAllProxies = SettingsReader.Read(rootNamespace, "ForwardedHeaders.TrustAllProxies", true); + + var knownProxiesValue = SettingsReader.Read(rootNamespace, "ForwardedHeaders.KnownProxies"); + if (!string.IsNullOrWhiteSpace(knownProxiesValue)) + { + KnownProxiesRaw = ParseAndValidateIPAddresses(knownProxiesValue); + } + + var knownNetworksValue = SettingsReader.Read(rootNamespace, "ForwardedHeaders.KnownNetworks"); + if (!string.IsNullOrWhiteSpace(knownNetworksValue)) + { + KnownNetworks = ParseNetworks(knownNetworksValue); + } + + // If proxies or networks are explicitly configured, disable TrustAllProxies + if ((KnownProxiesRaw.Count > 0 || KnownNetworks.Count > 0) && TrustAllProxies) + { + logger.LogInformation("KnownProxies or KnownNetworks configured, setting TrustAllProxies to false"); + TrustAllProxies = false; + } + + LogConfiguration(); + } + + public bool Enabled { get; } + + public bool TrustAllProxies { get; private set; } + + // Store as strings for serialization compatibility, parse to IPAddress when needed + public List KnownProxiesRaw { get; } = []; + + public List KnownNetworks { get; } = []; + + // Parse IPAddresses on demand to avoid serialization issues + [JsonIgnore] + public IEnumerable KnownProxies + { + get + { + foreach (var raw in KnownProxiesRaw) + { + if (IPAddress.TryParse(raw, out var address)) + { + yield return address; + } + } + } + } + + List ParseAndValidateIPAddresses(string value) + { + var addresses = new List(); + var parts = value.Split([',', ';'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + foreach (var part in parts) + { + if (IPAddress.TryParse(part, out _)) + { + addresses.Add(part); + } + else + { + logger.LogWarning("Invalid IP address in ForwardedHeaders.KnownProxies: '{InvalidAddress}'", part); + } + } + + return addresses; + } + + List ParseNetworks(string value) + { + var networks = new List(); + var parts = value.Split([',', ';'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + foreach (var part in parts) + { + // Basic validation - should contain a / + if (part.Contains('/')) + { + networks.Add(part); + } + else + { + logger.LogWarning("Invalid network CIDR in ForwardedHeaders.KnownNetworks (expected format: '10.0.0.0/8'): '{InvalidNetwork}'", part); + } + } + + return networks; + } + + void LogConfiguration() + { + if (!Enabled) + { + logger.LogInformation("Forwarded headers processing is disabled"); + return; + } + + logger.LogInformation("Forwarded headers configuration:"); + logger.LogInformation(" Enabled: {Enabled}", Enabled); + logger.LogInformation(" TrustAllProxies: {TrustAllProxies}", TrustAllProxies); + + if (KnownProxiesRaw.Count > 0) + { + logger.LogInformation(" KnownProxies: {KnownProxies}", string.Join(", ", KnownProxiesRaw)); + } + + if (KnownNetworks.Count > 0) + { + logger.LogInformation(" KnownNetworks: {KnownNetworks}", string.Join(", ", KnownNetworks)); + } + + if (TrustAllProxies) + { + logger.LogWarning("ForwardedHeaders.TrustAllProxies is true. Any client can spoof X-Forwarded-* headers. Consider configuring KnownProxies or KnownNetworks for production environments."); + } + } +} diff --git a/src/ServiceControl.Infrastructure/HttpsSettings.cs b/src/ServiceControl.Infrastructure/HttpsSettings.cs new file mode 100644 index 0000000000..d0954dd474 --- /dev/null +++ b/src/ServiceControl.Infrastructure/HttpsSettings.cs @@ -0,0 +1,106 @@ +namespace ServiceControl.Infrastructure; + +using System; +using System.IO; +using Microsoft.Extensions.Logging; +using ServiceControl.Configuration; + +public class HttpsSettings +{ + readonly ILogger logger = LoggerUtil.CreateStaticLogger(); + + public HttpsSettings(SettingsRootNamespace rootNamespace) + { + // Kestrel HTTPS - disabled by default for backwards compatibility + Enabled = SettingsReader.Read(rootNamespace, "Https.Enabled", false); + + if (Enabled) + { + CertificatePath = SettingsReader.Read(rootNamespace, "Https.CertificatePath"); + CertificatePassword = SettingsReader.Read(rootNamespace, "Https.CertificatePassword"); + + ValidateCertificateConfiguration(); + } + + // HTTPS redirection - disabled by default for backwards compatibility + RedirectHttpToHttps = SettingsReader.Read(rootNamespace, "Https.RedirectHttpToHttps", false); + + // HSTS - disabled by default, only applies in non-development environments + EnableHsts = SettingsReader.Read(rootNamespace, "Https.EnableHsts", false); + HstsMaxAgeSeconds = SettingsReader.Read(rootNamespace, "Https.HstsMaxAgeSeconds", 31536000); // 1 year default + HstsIncludeSubDomains = SettingsReader.Read(rootNamespace, "Https.HstsIncludeSubDomains", false); + + LogConfiguration(); + } + + /// + /// When true, Kestrel will be configured to listen on HTTPS using the specified certificate. + /// + public bool Enabled { get; } + + /// + /// Path to the HTTPS certificate file (.pfx or .pem). + /// Required when Https.Enabled is true. + /// + public string CertificatePath { get; } + + /// + /// Password for the HTTPS certificate. + /// Can be null for certificates without a password. + /// + public string CertificatePassword { get; } + + public bool RedirectHttpToHttps { get; } + + public bool EnableHsts { get; } + + public int HstsMaxAgeSeconds { get; } + + public bool HstsIncludeSubDomains { get; } + + void ValidateCertificateConfiguration() + { + if (string.IsNullOrWhiteSpace(CertificatePath)) + { + throw new InvalidOperationException( + "Https.Enabled is true but Https.CertificatePath is not configured. " + + "Please specify the path to a valid HTTPS certificate file (.pfx or .pem)."); + } + + if (!File.Exists(CertificatePath)) + { + throw new InvalidOperationException( + $"Https.CertificatePath '{CertificatePath}' does not exist. " + + "Please specify a valid path to an HTTPS certificate file."); + } + } + + void LogConfiguration() + { + if (!Enabled && !RedirectHttpToHttps && !EnableHsts) + { + return; + } + + logger.LogInformation("HTTPS configuration:"); + + if (Enabled) + { + logger.LogInformation(" Enabled: {Enabled}", Enabled); + logger.LogInformation(" CertificatePath: {CertificatePath}", CertificatePath); + logger.LogInformation(" CertificatePassword: {CertificatePassword}", string.IsNullOrEmpty(CertificatePassword) ? "(not set)" : "(set)"); + } + + if (RedirectHttpToHttps) + { + logger.LogInformation(" RedirectHttpToHttps: {RedirectHttpToHttps}", RedirectHttpToHttps); + } + + if (EnableHsts) + { + logger.LogInformation(" EnableHsts: {EnableHsts}", EnableHsts); + logger.LogInformation(" HstsMaxAgeSeconds: {HstsMaxAgeSeconds}", HstsMaxAgeSeconds); + logger.LogInformation(" HstsIncludeSubDomains: {HstsIncludeSubDomains}", HstsIncludeSubDomains); + } + } +} diff --git a/src/ServiceControl.Infrastructure/OpenIdConnectSettings.cs b/src/ServiceControl.Infrastructure/OpenIdConnectSettings.cs index 6443b0198e..02601c2168 100644 --- a/src/ServiceControl.Infrastructure/OpenIdConnectSettings.cs +++ b/src/ServiceControl.Infrastructure/OpenIdConnectSettings.cs @@ -9,7 +9,7 @@ public class OpenIdConnectSettings { readonly ILogger logger = LoggerUtil.CreateStaticLogger(); - public OpenIdConnectSettings(SettingsRootNamespace rootNamespace, bool validateConfiguration) + public OpenIdConnectSettings(SettingsRootNamespace rootNamespace, bool validateConfiguration, bool requireServicePulseSettings = true) { Enabled = SettingsReader.Read(rootNamespace, "Authentication.Enabled", false); @@ -25,13 +25,18 @@ public OpenIdConnectSettings(SettingsRootNamespace rootNamespace, bool validateC ValidateLifetime = SettingsReader.Read(rootNamespace, "Authentication.ValidateLifetime", true); ValidateIssuerSigningKey = SettingsReader.Read(rootNamespace, "Authentication.ValidateIssuerSigningKey", true); RequireHttpsMetadata = SettingsReader.Read(rootNamespace, "Authentication.RequireHttpsMetadata", true); - ServicePulseClientId = SettingsReader.Read(rootNamespace, "Authentication.ServicePulse.ClientId"); - ServicePulseApiScopes = SettingsReader.Read(rootNamespace, "Authentication.ServicePulse.ApiScopes"); - ServicePulseAuthority = SettingsReader.Read(rootNamespace, "Authentication.ServicePulse.Authority"); + + // ServicePulse settings are only needed for the primary ServiceControl instance + if (requireServicePulseSettings) + { + ServicePulseClientId = SettingsReader.Read(rootNamespace, "Authentication.ServicePulse.ClientId"); + ServicePulseApiScopes = SettingsReader.Read(rootNamespace, "Authentication.ServicePulse.ApiScopes"); + ServicePulseAuthority = SettingsReader.Read(rootNamespace, "Authentication.ServicePulse.Authority"); + } if (validateConfiguration) { - Validate(); + Validate(requireServicePulseSettings); } } @@ -68,7 +73,7 @@ public OpenIdConnectSettings(SettingsRootNamespace rootNamespace, bool validateC [JsonPropertyName("servicePulseApiScopes")] public string ServicePulseApiScopes { get; } - void Validate() + void Validate(bool requireServicePulseSettings) { if (!Enabled) { @@ -123,19 +128,23 @@ void Validate() logger.LogWarning("Authentication.ValidateIssuerSigningKey is set to false. This is a serious security risk and should only be used in development environments"); } - if (string.IsNullOrWhiteSpace(ServicePulseClientId)) - { - throw new Exception("Authentication.ServicePulse.ClientId is required when Authentication.ServicePulse.Enabled is true."); - } - - if (string.IsNullOrWhiteSpace(ServicePulseApiScopes)) + // ServicePulse settings are only required for the primary ServiceControl instance + if (requireServicePulseSettings) { - throw new Exception("Authentication.ServicePulse.ApiScopes is required when Authentication.ServicePulse.Enabled is true."); - } - - if (ServicePulseAuthority != null && !Uri.TryCreate(ServicePulseAuthority, UriKind.Absolute, out _)) - { - throw new Exception("Authentication.ServicePulse.Authority must be a valid absolute URI if provided."); + if (string.IsNullOrWhiteSpace(ServicePulseClientId)) + { + throw new Exception("Authentication.ServicePulse.ClientId is required when authentication is enabled on the primary ServiceControl instance."); + } + + if (string.IsNullOrWhiteSpace(ServicePulseApiScopes)) + { + throw new Exception("Authentication.ServicePulse.ApiScopes is required when authentication is enabled on the primary ServiceControl instance."); + } + + if (ServicePulseAuthority != null && !Uri.TryCreate(ServicePulseAuthority, UriKind.Absolute, out _)) + { + throw new Exception("Authentication.ServicePulse.Authority must be a valid absolute URI if provided."); + } } logger.LogInformation("Authentication configuration validated successfully"); @@ -146,8 +155,12 @@ void Validate() logger.LogInformation(" ValidateLifetime: {ValidateLifetime}", ValidateLifetime); logger.LogInformation(" ValidateIssuerSigningKey: {ValidateIssuerSigningKey}", ValidateIssuerSigningKey); logger.LogInformation(" RequireHttpsMetadata: {RequireHttpsMetadata}", RequireHttpsMetadata); - logger.LogInformation(" ServicePulseClientId: {ServicePulseClientId}", ServicePulseClientId); - logger.LogInformation(" ServicePulseAuthority: {ServicePulseAuthority}", ServicePulseAuthority); - logger.LogInformation(" ServicePulseApiScopes: {ServicePulseApiScopes}", ServicePulseApiScopes); + + if (requireServicePulseSettings) + { + logger.LogInformation(" ServicePulseClientId: {ServicePulseClientId}", ServicePulseClientId); + logger.LogInformation(" ServicePulseAuthority: {ServicePulseAuthority}", ServicePulseAuthority); + logger.LogInformation(" ServicePulseApiScopes: {ServicePulseApiScopes}", ServicePulseApiScopes); + } } } diff --git a/src/ServiceControl.Monitoring.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs b/src/ServiceControl.Monitoring.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs index 00c545bd97..eb2f491072 100644 --- a/src/ServiceControl.Monitoring.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs +++ b/src/ServiceControl.Monitoring.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs @@ -113,7 +113,7 @@ async Task InitializeServiceControl(ScenarioContext context) hostBuilder.AddServiceControlMonitoringTesting(settings); host = hostBuilder.Build(); - host.UseServiceControlMonitoring(); + host.UseServiceControlMonitoring(settings.ForwardedHeadersSettings, settings.HttpsSettings, settings.CorsSettings); await host.StartAsync(); HttpClient = host.Services.GetRequiredKeyedService(settings.InstanceName).CreateClient(); diff --git a/src/ServiceControl.Monitoring.UnitTests/ApprovalFiles/SettingsTests.PlatformSampleSettings.approved.txt b/src/ServiceControl.Monitoring.UnitTests/ApprovalFiles/SettingsTests.PlatformSampleSettings.approved.txt index 070234384e..ffdbac2332 100644 --- a/src/ServiceControl.Monitoring.UnitTests/ApprovalFiles/SettingsTests.PlatformSampleSettings.approved.txt +++ b/src/ServiceControl.Monitoring.UnitTests/ApprovalFiles/SettingsTests.PlatformSampleSettings.approved.txt @@ -3,6 +3,38 @@ "LogLevel": "Information", "LogPath": "C:\\Logs" }, + "OpenIdConnectSettings": { + "enabled": false, + "authority": null, + "audience": null, + "validateIssuer": false, + "validateAudience": false, + "validateLifetime": false, + "validateIssuerSigningKey": false, + "requireHttpsMetadata": false, + "servicePulseAuthority": null, + "servicePulseClientId": null, + "servicePulseApiScopes": null + }, + "ForwardedHeadersSettings": { + "Enabled": true, + "TrustAllProxies": true, + "KnownProxiesRaw": [], + "KnownNetworks": [] + }, + "HttpsSettings": { + "Enabled": false, + "CertificatePath": null, + "CertificatePassword": null, + "RedirectHttpToHttps": false, + "EnableHsts": false, + "HstsMaxAgeSeconds": 31536000, + "HstsIncludeSubDomains": false + }, + "CorsSettings": { + "AllowAnyOrigin": true, + "AllowedOrigins": [] + }, "InstanceName": "Particular.Monitoring", "TransportType": "NServiceBus.ServiceControlLearningTransport, ServiceControl.Transports.LearningTransport", "ConnectionString": null, @@ -13,5 +45,6 @@ "RootUrl": "http://localhost:9999/", "MaximumConcurrencyLevel": null, "ServiceControlThroughputDataQueue": "ServiceControl.ThroughputData", + "ValidateConfiguration": true, "ShutdownTimeout": "00:00:05" } \ No newline at end of file diff --git a/src/ServiceControl.Monitoring/App.config b/src/ServiceControl.Monitoring/App.config index 62f4a54778..6521e32445 100644 --- a/src/ServiceControl.Monitoring/App.config +++ b/src/ServiceControl.Monitoring/App.config @@ -25,21 +25,42 @@ These settings are only here so that we can debug ServiceControl while developin - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/ServiceControl.Monitoring/Hosting/Commands/RunCommand.cs b/src/ServiceControl.Monitoring/Hosting/Commands/RunCommand.cs index 7f672f41bb..ca648ac222 100644 --- a/src/ServiceControl.Monitoring/Hosting/Commands/RunCommand.cs +++ b/src/ServiceControl.Monitoring/Hosting/Commands/RunCommand.cs @@ -6,6 +6,7 @@ namespace ServiceControl.Monitoring using Microsoft.AspNetCore.Builder; using NServiceBus; using ServiceControl.Hosting.Auth; + using ServiceControl.Hosting.Https; class RunCommand : AbstractCommand { @@ -15,11 +16,12 @@ public override async Task Execute(HostArguments args, Settings settings) var hostBuilder = WebApplication.CreateBuilder(); hostBuilder.AddServiceControlAuthentication(settings.OpenIdConnectSettings); + hostBuilder.AddServiceControlHttps(settings.HttpsSettings); hostBuilder.AddServiceControlMonitoring((_, __) => Task.CompletedTask, settings, endpointConfiguration); hostBuilder.AddServiceControlMonitoringApi(); var app = hostBuilder.Build(); - app.UseServiceControlMonitoring(); + app.UseServiceControlMonitoring(settings.ForwardedHeadersSettings, settings.HttpsSettings, settings.CorsSettings); app.UseServiceControlAuthentication(authenticationEnabled: settings.OpenIdConnectSettings.Enabled); await app.RunAsync(settings.RootUrl); diff --git a/src/ServiceControl.Monitoring/Properties/launchSettings.json b/src/ServiceControl.Monitoring/Properties/launchSettings.json index 5365c2028e..beac1927c4 100644 --- a/src/ServiceControl.Monitoring/Properties/launchSettings.json +++ b/src/ServiceControl.Monitoring/Properties/launchSettings.json @@ -3,6 +3,7 @@ "ServiceControl.Monitoring": { "commandName": "Project", "launchBrowser": false, + "applicationUrl": "http://0.0.0.0:33633", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/src/ServiceControl.Monitoring/Settings.cs b/src/ServiceControl.Monitoring/Settings.cs index 361cf785a8..e58b6364d6 100644 --- a/src/ServiceControl.Monitoring/Settings.cs +++ b/src/ServiceControl.Monitoring/Settings.cs @@ -16,7 +16,10 @@ public Settings(LoggingSettings loggingSettings = null, string transportType = n { LoggingSettings = loggingSettings ?? new(SettingsRootNamespace); - OpenIdConnectSettings = new OpenIdConnectSettings(SettingsRootNamespace, false); + OpenIdConnectSettings = new OpenIdConnectSettings(SettingsRootNamespace, ValidateConfiguration, requireServicePulseSettings: false); + ForwardedHeadersSettings = new ForwardedHeadersSettings(SettingsRootNamespace); + HttpsSettings = new HttpsSettings(SettingsRootNamespace); + CorsSettings = new CorsSettings(SettingsRootNamespace); // Overwrite the instance name if it is specified in ENVVAR, reg, or config file InstanceName = SettingsReader.Read(SettingsRootNamespace, "InstanceName", InstanceName); @@ -53,6 +56,12 @@ public Settings(LoggingSettings loggingSettings = null, string transportType = n public OpenIdConnectSettings OpenIdConnectSettings { get; } + public ForwardedHeadersSettings ForwardedHeadersSettings { get; } + + public HttpsSettings HttpsSettings { get; } + + public CorsSettings CorsSettings { get; } + public string InstanceName { get; init; } = DEFAULT_INSTANCE_NAME; public string TransportType { get; set; } @@ -67,12 +76,14 @@ public Settings(LoggingSettings loggingSettings = null, string transportType = n public TimeSpan EndpointUptimeGracePeriod { get; set; } - public string RootUrl => $"http://{HttpHostName}:{HttpPort}/"; + public string RootUrl => $"{(HttpsSettings.Enabled ? "https" : "http")}://{HttpHostName}:{HttpPort}/"; public int? MaximumConcurrencyLevel { get; set; } public string ServiceControlThroughputDataQueue { get; set; } + public bool ValidateConfiguration => SettingsReader.Read(SettingsRootNamespace, "ValidateConfig", true); + // The default value is set to the maximum allowed time by the most // restrictive hosting platform, which is Linux containers. Linux // containers allow for a maximum of 10 seconds. We set it to 5 to diff --git a/src/ServiceControl.Monitoring/WebApplicationExtensions.cs b/src/ServiceControl.Monitoring/WebApplicationExtensions.cs index db4e2d3a3e..efceb80c46 100644 --- a/src/ServiceControl.Monitoring/WebApplicationExtensions.cs +++ b/src/ServiceControl.Monitoring/WebApplicationExtensions.cs @@ -1,21 +1,33 @@ namespace ServiceControl.Monitoring.Infrastructure; using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.HttpOverrides; +using ServiceControl.Hosting.ForwardedHeaders; +using ServiceControl.Hosting.Https; +using ServiceControl.Infrastructure; public static class WebApplicationExtensions { - public static void UseServiceControlMonitoring(this WebApplication appBuilder) + public static void UseServiceControlMonitoring(this WebApplication appBuilder, ForwardedHeadersSettings forwardedHeadersSettings, HttpsSettings httpsSettings, CorsSettings corsSettings) { - appBuilder.UseForwardedHeaders(new ForwardedHeadersOptions { ForwardedHeaders = ForwardedHeaders.All }); + appBuilder.UseServiceControlForwardedHeaders(forwardedHeadersSettings); + appBuilder.UseServiceControlHttps(httpsSettings); appBuilder.UseHttpLogging(); appBuilder.UseCors(policyBuilder => { - policyBuilder.AllowAnyOrigin(); + if (corsSettings.AllowAnyOrigin) + { + policyBuilder.AllowAnyOrigin(); + } + else if (corsSettings.AllowedOrigins.Count > 0) + { + policyBuilder.WithOrigins([.. corsSettings.AllowedOrigins]); + policyBuilder.AllowCredentials(); + } + policyBuilder.WithExposedHeaders(["ETag", "Last-Modified", "Link", "Total-Count", "X-Particular-Version"]); - policyBuilder.WithHeaders(["Origin", "X-Requested-With", "Content-Type", "Accept"]); + policyBuilder.WithHeaders(["Origin", "X-Requested-With", "Content-Type", "Accept", "Authorization"]); policyBuilder.WithMethods(["POST", "GET", "PUT", "DELETE", "OPTIONS", "PATCH"]); }); diff --git a/src/ServiceControl.Persistence.RavenDB/ErrorMessagesDataStore.cs b/src/ServiceControl.Persistence.RavenDB/ErrorMessagesDataStore.cs index 4940807646..549a37d946 100644 --- a/src/ServiceControl.Persistence.RavenDB/ErrorMessagesDataStore.cs +++ b/src/ServiceControl.Persistence.RavenDB/ErrorMessagesDataStore.cs @@ -177,8 +177,8 @@ public async Task StoreFailedErrorImport(FailedErrorImport failure) await session.SaveChangesAsync(); } + // the edit failed message manager manages the lifetime of the session public async Task CreateEditFailedMessageManager() => - // the edit failed message manager manages the lifetime of the session new EditFailedMessageManager(await sessionProvider.OpenSession(), expirationManager); public async Task> GetFailureGroupView(string groupId, string status, string modified) @@ -323,8 +323,8 @@ async Task ErrorByDocumentId(string documentId) return message; } + // the notifications manager manages the lifetime of the session public async Task CreateNotificationsManager() => - // the notifications manager manages the lifetime of the session new NotificationsManager(await sessionProvider.OpenSession()); public async Task ErrorLastBy(string failedMessageId) diff --git a/src/ServiceControl.Persistence/RetryHistory.cs b/src/ServiceControl.Persistence/RetryHistory.cs index 995c9875fb..f1472f94b1 100644 --- a/src/ServiceControl.Persistence/RetryHistory.cs +++ b/src/ServiceControl.Persistence/RetryHistory.cs @@ -45,8 +45,8 @@ public void AddToUnacknowledged(UnacknowledgedRetryOperation unacknowledgedRetry { UnacknowledgedOperations.Add(unacknowledgedRetryOperation); + // All other retry types already have an explicit way to dismiss them on the UI UnacknowledgedOperations = UnacknowledgedOperations - // All other retry types already have an explicit way to dismiss them on the UI .Where(operation => operation.RetryType is not RetryType.MultipleMessages and not RetryType.SingleMessage) .ToList(); } diff --git a/src/ServiceControl.SagaAudit/CustomTimeSpanConverter.cs b/src/ServiceControl.SagaAudit/CustomTimeSpanConverter.cs index 87b3efd1bb..bfa5470c32 100644 --- a/src/ServiceControl.SagaAudit/CustomTimeSpanConverter.cs +++ b/src/ServiceControl.SagaAudit/CustomTimeSpanConverter.cs @@ -57,9 +57,9 @@ public override TimeSpan Read(ref Utf8JsonReader reader, Type typeToConvert, Jso } // Ut8Parser.TryParse also handles some short format "g" cases which has a minimum of 1 chars independent of the format identifier - if ((!Utf8Parser.TryParse(source, out TimeSpan parsedTimeSpan, out int bytesConsumed, 'c') || source.Length != bytesConsumed) && - // Otherwise we fall back to read with the short format "g" directly since that is what the SagaAudit plugin used to stay backward compatible - (!Utf8Parser.TryParse(source, out parsedTimeSpan, out bytesConsumed, 'g') || source.Length != bytesConsumed)) + // Otherwise we fall back to read with the short format "g" directly since that is what the SagaAudit plugin used to stay backward compatible + if ((!Utf8Parser.TryParse(source, out TimeSpan parsedTimeSpan, out int bytesConsumed, 'c') || source.Length != bytesConsumed) + && (!Utf8Parser.TryParse(source, out parsedTimeSpan, out bytesConsumed, 'g') || source.Length != bytesConsumed)) { ThrowFormatException(); } diff --git a/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt b/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt index 4b8cd828ba..9ad5f0d751 100644 --- a/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt +++ b/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt @@ -3,6 +3,38 @@ "LogLevel": "Information", "LogPath": "C:\\Logs" }, + "OpenIdConnectSettings": { + "enabled": false, + "authority": null, + "audience": null, + "validateIssuer": false, + "validateAudience": false, + "validateLifetime": false, + "validateIssuerSigningKey": false, + "requireHttpsMetadata": false, + "servicePulseAuthority": null, + "servicePulseClientId": null, + "servicePulseApiScopes": null + }, + "ForwardedHeadersSettings": { + "Enabled": true, + "TrustAllProxies": true, + "KnownProxiesRaw": [], + "KnownNetworks": [] + }, + "HttpsSettings": { + "Enabled": false, + "CertificatePath": null, + "CertificatePassword": null, + "RedirectHttpToHttps": false, + "EnableHsts": false, + "HstsMaxAgeSeconds": 31536000, + "HstsIncludeSubDomains": false + }, + "CorsSettings": { + "AllowAnyOrigin": true, + "AllowedOrigins": [] + }, "NotificationsFilter": null, "AllowMessageEditing": false, "MessageFilter": null, @@ -40,18 +72,5 @@ "RetryHistoryDepth": 10, "RemoteInstances": [], "DisableHealthChecks": false, - "OpenIdConnectSettings": { - "enabled": false, - "authority": null, - "audience": null, - "validateIssuer": false, - "validateAudience": false, - "validateLifetime": false, - "validateIssuerSigningKey": false, - "requireHttpsMetadata": false, - "servicePulseAuthority": null, - "servicePulseClientId": null, - "servicePulseApiScopes": null - }, "ShutdownTimeout": "00:00:05" } \ No newline at end of file diff --git a/src/ServiceControl/App.config b/src/ServiceControl/App.config index 2144640f19..16a44d67c3 100644 --- a/src/ServiceControl/App.config +++ b/src/ServiceControl/App.config @@ -7,7 +7,7 @@ These settings are only here so that we can debug ServiceControl while developin - + @@ -30,22 +30,48 @@ These settings are only here so that we can debug ServiceControl while developin - - - - + + + + + + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/ServiceControl/Authentication/AuthenticationController.cs b/src/ServiceControl/Authentication/AuthenticationController.cs index 9f23036a3b..e5cae45d0a 100644 --- a/src/ServiceControl/Authentication/AuthenticationController.cs +++ b/src/ServiceControl/Authentication/AuthenticationController.cs @@ -2,7 +2,9 @@ { using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; + using Microsoft.AspNetCore.RateLimiting; using ServiceBus.Management.Infrastructure.Settings; + using ServiceControl.Infrastructure.WebApi; [ApiController] [Route("api/authentication")] @@ -10,6 +12,7 @@ public class AuthenticationController(Settings settings) : ControllerBase { [HttpGet] [AllowAnonymous] + [EnableRateLimiting(HostApplicationBuilderExtensions.AuthConfigRateLimitPolicy)] [Route("configuration")] public ActionResult Configuration() { diff --git a/src/ServiceControl/CompositeViews/AuditCounts/GetAuditCountsForEndpointApi.cs b/src/ServiceControl/CompositeViews/AuditCounts/GetAuditCountsForEndpointApi.cs index b260282a01..c80b3679b7 100644 --- a/src/ServiceControl/CompositeViews/AuditCounts/GetAuditCountsForEndpointApi.cs +++ b/src/ServiceControl/CompositeViews/AuditCounts/GetAuditCountsForEndpointApi.cs @@ -26,8 +26,8 @@ public class GetAuditCountsForEndpointApi( { static readonly IList Empty = new List(0).AsReadOnly(); + // Will never be implemented on the primary instance protected override Task>> LocalQuery(AuditCountsForEndpointContext input) => - // Will never be implemented on the primary instance Task.FromResult(new QueryResult>(Empty, QueryStatsInfo.Zero)); protected override IList ProcessResults(AuditCountsForEndpointContext input, QueryResult>[] results) => diff --git a/src/ServiceControl/Hosting/Commands/ImportFailedErrorsCommand.cs b/src/ServiceControl/Hosting/Commands/ImportFailedErrorsCommand.cs index 4adafa724f..105f756daf 100644 --- a/src/ServiceControl/Hosting/Commands/ImportFailedErrorsCommand.cs +++ b/src/ServiceControl/Hosting/Commands/ImportFailedErrorsCommand.cs @@ -26,7 +26,7 @@ public override async Task Execute(HostArguments args, Settings settings) var hostBuilder = Host.CreateApplicationBuilder(); hostBuilder.AddServiceControl(settings, endpointConfiguration); - hostBuilder.AddServiceControlApi(); + hostBuilder.AddServiceControlApi(settings.CorsSettings); using var app = hostBuilder.Build(); await app.StartAsync(); diff --git a/src/ServiceControl/Hosting/Commands/RunCommand.cs b/src/ServiceControl/Hosting/Commands/RunCommand.cs index 1c2da74f43..ac5cd439b4 100644 --- a/src/ServiceControl/Hosting/Commands/RunCommand.cs +++ b/src/ServiceControl/Hosting/Commands/RunCommand.cs @@ -9,6 +9,7 @@ using ServiceBus.Management.Infrastructure.Settings; using ServiceControl; using ServiceControl.Hosting.Auth; + using ServiceControl.Hosting.Https; class RunCommand : AbstractCommand { @@ -23,12 +24,13 @@ public override async Task Execute(HostArguments args, Settings settings) var hostBuilder = WebApplication.CreateBuilder(); hostBuilder.AddServiceControlAuthentication(settings.OpenIdConnectSettings); + hostBuilder.AddServiceControlHttps(settings.HttpsSettings); hostBuilder.AddServiceControl(settings, endpointConfiguration); - hostBuilder.AddServiceControlApi(); + hostBuilder.AddServiceControlApi(settings.CorsSettings); var app = hostBuilder.Build(); - app.UseServiceControl(); - app.UseServiceControlAuthentication(authenticationEnabled: settings.OpenIdConnectSettings.Enabled); + app.UseServiceControl(settings.ForwardedHeadersSettings, settings.HttpsSettings); + app.UseServiceControlAuthentication(settings.OpenIdConnectSettings.Enabled); await app.RunAsync(settings.RootUrl); } diff --git a/src/ServiceControl/Infrastructure/Settings/Settings.cs b/src/ServiceControl/Infrastructure/Settings/Settings.cs index e6ee8b95e1..6b447dfb8f 100644 --- a/src/ServiceControl/Infrastructure/Settings/Settings.cs +++ b/src/ServiceControl/Infrastructure/Settings/Settings.cs @@ -30,6 +30,9 @@ public Settings( LoggingSettings = loggingSettings ?? new(SettingsRootNamespace); OpenIdConnectSettings = new OpenIdConnectSettings(SettingsRootNamespace, ValidateConfiguration); + ForwardedHeadersSettings = new ForwardedHeadersSettings(SettingsRootNamespace); + HttpsSettings = new HttpsSettings(SettingsRootNamespace); + CorsSettings = new CorsSettings(SettingsRootNamespace); // Overwrite the instance name if it is specified in ENVVAR, reg, or config file -- LEGACY SETTING NAME InstanceName = SettingsReader.Read(SettingsRootNamespace, "InternalQueueName", InstanceName); @@ -78,6 +81,12 @@ public Settings( public OpenIdConnectSettings OpenIdConnectSettings { get; } + public ForwardedHeadersSettings ForwardedHeadersSettings { get; } + + public HttpsSettings HttpsSettings { get; } + + public CorsSettings CorsSettings { get; } + public string NotificationsFilter { get; set; } public bool AllowMessageEditing { get; set; } @@ -107,7 +116,8 @@ public string RootUrl suffix = $"{VirtualDirectory}/"; } - return $"http://{Hostname}:{Port}/{suffix}"; + var scheme = HttpsSettings.Enabled ? "https" : "http"; + return $"{scheme}://{Hostname}:{Port}/{suffix}"; } } diff --git a/src/ServiceControl/Infrastructure/WebApi/Cors.cs b/src/ServiceControl/Infrastructure/WebApi/Cors.cs index 14abc35bbc..8a01728378 100644 --- a/src/ServiceControl/Infrastructure/WebApi/Cors.cs +++ b/src/ServiceControl/Infrastructure/WebApi/Cors.cs @@ -4,11 +4,20 @@ static class Cors { - public static CorsPolicy GetDefaultPolicy() + public static CorsPolicy GetDefaultPolicy(CorsSettings settings) { var builder = new CorsPolicyBuilder(); - builder.AllowAnyOrigin(); + if (settings.AllowAnyOrigin) + { + builder.AllowAnyOrigin(); + } + else if (settings.AllowedOrigins.Count > 0) + { + builder.WithOrigins([.. settings.AllowedOrigins]); + builder.AllowCredentials(); + } + builder.WithExposedHeaders(["ETag", "Last-Modified", "Link", "Total-Count", "X-Particular-Version", "Content-Disposition"]); builder.WithHeaders(["Origin", "X-Requested-With", "Content-Type", "Accept", "Authorization"]); builder.WithMethods(["POST", "GET", "PUT", "DELETE", "OPTIONS", "PATCH", "HEAD"]); diff --git a/src/ServiceControl/Infrastructure/WebApi/HostApplicationBuilderExtensions.cs b/src/ServiceControl/Infrastructure/WebApi/HostApplicationBuilderExtensions.cs index ec40af1e10..cbdda10211 100644 --- a/src/ServiceControl/Infrastructure/WebApi/HostApplicationBuilderExtensions.cs +++ b/src/ServiceControl/Infrastructure/WebApi/HostApplicationBuilderExtensions.cs @@ -1,9 +1,12 @@ namespace ServiceControl.Infrastructure.WebApi { + using System; using System.Linq; using System.Reflection; + using System.Threading.RateLimiting; using CompositeViews.Messages; using Microsoft.AspNetCore.Builder; + using Microsoft.AspNetCore.RateLimiting; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; @@ -12,7 +15,9 @@ static class HostApplicationBuilderExtensions { - public static void AddServiceControlApi(this IHostApplicationBuilder builder) + public const string AuthConfigRateLimitPolicy = "AuthConfigRateLimit"; + + public static void AddServiceControlApi(this IHostApplicationBuilder builder, CorsSettings corsSettings) { // This registers concrete classes that implement IApi. Currently it is hard to find out to what // component those APIs should belong to so we leave it here for now. @@ -20,7 +25,20 @@ public static void AddServiceControlApi(this IHostApplicationBuilder builder) builder.AddServiceControlApis(); - builder.Services.AddCors(options => options.AddDefaultPolicy(Cors.GetDefaultPolicy())); + builder.Services.AddCors(options => options.AddDefaultPolicy(Cors.GetDefaultPolicy(corsSettings))); + + // Rate limiting for sensitive endpoints to prevent enumeration attacks + builder.Services.AddRateLimiter(options => + { + options.AddFixedWindowLimiter(AuthConfigRateLimitPolicy, limiterOptions => + { + limiterOptions.PermitLimit = 10; + limiterOptions.Window = TimeSpan.FromMinutes(1); + limiterOptions.QueueProcessingOrder = QueueProcessingOrder.OldestFirst; + limiterOptions.QueueLimit = 2; + }); + options.RejectionStatusCode = 429; + }); // We're not explicitly adding Gzip here because it's already in the default list of supported compressors builder.Services.AddResponseCompression(); diff --git a/src/ServiceControl/Operations/ErrorProcessor.cs b/src/ServiceControl/Operations/ErrorProcessor.cs index a539042eba..5988f4333f 100644 --- a/src/ServiceControl/Operations/ErrorProcessor.cs +++ b/src/ServiceControl/Operations/ErrorProcessor.cs @@ -45,8 +45,8 @@ public async Task> Process(IReadOnlyList(); foreach (var context in contexts) { + // Any message context that failed during processing will have a faulted task and should be skipped if (!context.Extensions.TryGet(out _) || - // Any message context that failed during processing will have a faulted task and should be skipped context.GetTaskCompletionSource().Task.IsFaulted) { continue; diff --git a/src/ServiceControl/Properties/launchSettings.json b/src/ServiceControl/Properties/launchSettings.json index e9f96331bf..82bf837891 100644 --- a/src/ServiceControl/Properties/launchSettings.json +++ b/src/ServiceControl/Properties/launchSettings.json @@ -3,6 +3,7 @@ "ServiceControl": { "commandName": "Project", "launchBrowser": false, + "applicationUrl": "http://0.0.0.0:33333", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/src/ServiceControl/WebApplicationExtensions.cs b/src/ServiceControl/WebApplicationExtensions.cs index 407ad00c9c..15f59a552b 100644 --- a/src/ServiceControl/WebApplicationExtensions.cs +++ b/src/ServiceControl/WebApplicationExtensions.cs @@ -3,16 +3,20 @@ namespace ServiceControl; using Infrastructure.SignalR; using Infrastructure.WebApi; using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.HttpOverrides; +using ServiceControl.Hosting.ForwardedHeaders; +using ServiceControl.Hosting.Https; +using ServiceControl.Infrastructure; public static class WebApplicationExtensions { - public static void UseServiceControl(this WebApplication app) + public static void UseServiceControl(this WebApplication app, ForwardedHeadersSettings forwardedHeadersSettings, HttpsSettings httpsSettings) { - app.UseForwardedHeaders(new ForwardedHeadersOptions { ForwardedHeaders = ForwardedHeaders.All }); + app.UseServiceControlForwardedHeaders(forwardedHeadersSettings); + app.UseServiceControlHttps(httpsSettings); app.UseResponseCompression(); app.UseMiddleware(); app.UseHttpLogging(); + app.UseRateLimiter(); app.MapHub("/api/messagestream"); app.UseCors(); } From c33330283c7647393764a27fa834c2ff12cfd0d4 Mon Sep 17 00:00:00 2001 From: Warwick Schroeder Date: Thu, 4 Dec 2025 16:08:14 +0800 Subject: [PATCH 11/24] Remove previously added rate limit for anon api --- .../Authentication/AuthenticationController.cs | 3 --- .../WebApi/HostApplicationBuilderExtensions.cs | 18 ------------------ 2 files changed, 21 deletions(-) diff --git a/src/ServiceControl/Authentication/AuthenticationController.cs b/src/ServiceControl/Authentication/AuthenticationController.cs index e5cae45d0a..9f23036a3b 100644 --- a/src/ServiceControl/Authentication/AuthenticationController.cs +++ b/src/ServiceControl/Authentication/AuthenticationController.cs @@ -2,9 +2,7 @@ { using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; - using Microsoft.AspNetCore.RateLimiting; using ServiceBus.Management.Infrastructure.Settings; - using ServiceControl.Infrastructure.WebApi; [ApiController] [Route("api/authentication")] @@ -12,7 +10,6 @@ public class AuthenticationController(Settings settings) : ControllerBase { [HttpGet] [AllowAnonymous] - [EnableRateLimiting(HostApplicationBuilderExtensions.AuthConfigRateLimitPolicy)] [Route("configuration")] public ActionResult Configuration() { diff --git a/src/ServiceControl/Infrastructure/WebApi/HostApplicationBuilderExtensions.cs b/src/ServiceControl/Infrastructure/WebApi/HostApplicationBuilderExtensions.cs index cbdda10211..298885ae0f 100644 --- a/src/ServiceControl/Infrastructure/WebApi/HostApplicationBuilderExtensions.cs +++ b/src/ServiceControl/Infrastructure/WebApi/HostApplicationBuilderExtensions.cs @@ -1,12 +1,9 @@ namespace ServiceControl.Infrastructure.WebApi { - using System; using System.Linq; using System.Reflection; - using System.Threading.RateLimiting; using CompositeViews.Messages; using Microsoft.AspNetCore.Builder; - using Microsoft.AspNetCore.RateLimiting; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; @@ -15,8 +12,6 @@ static class HostApplicationBuilderExtensions { - public const string AuthConfigRateLimitPolicy = "AuthConfigRateLimit"; - public static void AddServiceControlApi(this IHostApplicationBuilder builder, CorsSettings corsSettings) { // This registers concrete classes that implement IApi. Currently it is hard to find out to what @@ -27,19 +22,6 @@ public static void AddServiceControlApi(this IHostApplicationBuilder builder, Co builder.Services.AddCors(options => options.AddDefaultPolicy(Cors.GetDefaultPolicy(corsSettings))); - // Rate limiting for sensitive endpoints to prevent enumeration attacks - builder.Services.AddRateLimiter(options => - { - options.AddFixedWindowLimiter(AuthConfigRateLimitPolicy, limiterOptions => - { - limiterOptions.PermitLimit = 10; - limiterOptions.Window = TimeSpan.FromMinutes(1); - limiterOptions.QueueProcessingOrder = QueueProcessingOrder.OldestFirst; - limiterOptions.QueueLimit = 2; - }); - options.RejectionStatusCode = 429; - }); - // We're not explicitly adding Gzip here because it's already in the default list of supported compressors builder.Services.AddResponseCompression(); var controllers = builder.Services.AddControllers(options => From 575e2be252eb92eecc469387314a0b25a95bd17d Mon Sep 17 00:00:00 2001 From: jasontaylordev Date: Fri, 5 Dec 2025 11:33:53 +1000 Subject: [PATCH 12/24] Forward auth header --- .../MessageView_ScatterGatherTest.cs | 7 ++--- .../GetAuditCountsForEndpointApi.cs | 4 ++- .../Messages/GetAllMessagesApi.cs | 5 ++-- .../Messages/GetAllMessagesForEndpointApi.cs | 5 ++-- .../Messages/MessagesByConversationApi.cs | 5 ++-- .../Messages/ScatterGatherApi.cs | 26 ++++++++++++++----- .../Messages/ScatterGatherApiMessageView.cs | 5 ++-- .../Messages/ScatterGatherRemoteOnly.cs | 5 ++-- .../CompositeViews/Messages/SearchApi.cs | 5 ++-- .../Messages/SearchEndpointApi.cs | 5 ++-- .../Monitoring/Web/GetKnownEndpointsApi.cs | 4 ++- .../SagaAudit/GetSagaByIdApi.cs | 5 ++-- 12 files changed, 54 insertions(+), 27 deletions(-) diff --git a/src/ServiceControl.UnitTests/ScatterGather/MessageView_ScatterGatherTest.cs b/src/ServiceControl.UnitTests/ScatterGather/MessageView_ScatterGatherTest.cs index 22eee8c632..4c327da5c8 100644 --- a/src/ServiceControl.UnitTests/ScatterGather/MessageView_ScatterGatherTest.cs +++ b/src/ServiceControl.UnitTests/ScatterGather/MessageView_ScatterGatherTest.cs @@ -6,6 +6,7 @@ using System.Net.Http; using System.Threading.Tasks; using CompositeViews.Messages; + using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using NUnit.Framework; @@ -17,7 +18,7 @@ abstract class MessageView_ScatterGatherTest [SetUp] public void SetUp() { - var api = new TestApi(null, null, null, NullLogger.Instance); + var api = new TestApi(null, null, null, null, NullLogger.Instance); Results = api.AggregateResults(new ScatterGatherApiMessageViewContext(new PagingInfo(), new SortInfo()), GetData()); } @@ -68,8 +69,8 @@ protected IEnumerable RemoteData() class TestApi : ScatterGatherApiMessageView { - public TestApi(object dataStore, Settings settings, IHttpClientFactory httpClientFactory, ILogger logger) - : base(dataStore, settings, httpClientFactory, logger) + public TestApi(object dataStore, Settings settings, IHttpClientFactory httpClientFactory, IHttpContextAccessor httpContextAccessor, ILogger logger) + : base(dataStore, settings, httpClientFactory, httpContextAccessor, logger) { } diff --git a/src/ServiceControl/CompositeViews/AuditCounts/GetAuditCountsForEndpointApi.cs b/src/ServiceControl/CompositeViews/AuditCounts/GetAuditCountsForEndpointApi.cs index c80b3679b7..229ad0f30e 100644 --- a/src/ServiceControl/CompositeViews/AuditCounts/GetAuditCountsForEndpointApi.cs +++ b/src/ServiceControl/CompositeViews/AuditCounts/GetAuditCountsForEndpointApi.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using Api.Contracts; using Messages; + using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Persistence; using Persistence.Infrastructure; @@ -21,8 +22,9 @@ public class GetAuditCountsForEndpointApi( IErrorMessageDataStore dataStore, Settings settings, IHttpClientFactory httpClientFactory, + IHttpContextAccessor httpContextAccessor, ILogger logger) - : ScatterGatherApi>(dataStore, settings, httpClientFactory, logger) + : ScatterGatherApi>(dataStore, settings, httpClientFactory, httpContextAccessor, logger) { static readonly IList Empty = new List(0).AsReadOnly(); diff --git a/src/ServiceControl/CompositeViews/Messages/GetAllMessagesApi.cs b/src/ServiceControl/CompositeViews/Messages/GetAllMessagesApi.cs index f3affe59e2..a6c867331f 100644 --- a/src/ServiceControl/CompositeViews/Messages/GetAllMessagesApi.cs +++ b/src/ServiceControl/CompositeViews/Messages/GetAllMessagesApi.cs @@ -3,6 +3,7 @@ namespace ServiceControl.CompositeViews.Messages using System.Collections.Generic; using System.Net.Http; using System.Threading.Tasks; + using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Persistence; using Persistence.Infrastructure; @@ -10,8 +11,8 @@ namespace ServiceControl.CompositeViews.Messages public class GetAllMessagesApi : ScatterGatherApiMessageView { - public GetAllMessagesApi(IErrorMessageDataStore dataStore, Settings settings, IHttpClientFactory httpClientFactory, ILogger logger) - : base(dataStore, settings, httpClientFactory, logger) + public GetAllMessagesApi(IErrorMessageDataStore dataStore, Settings settings, IHttpClientFactory httpClientFactory, IHttpContextAccessor httpContextAccessor, ILogger logger) + : base(dataStore, settings, httpClientFactory, httpContextAccessor, logger) { } diff --git a/src/ServiceControl/CompositeViews/Messages/GetAllMessagesForEndpointApi.cs b/src/ServiceControl/CompositeViews/Messages/GetAllMessagesForEndpointApi.cs index 20870b9fbf..526be6056b 100644 --- a/src/ServiceControl/CompositeViews/Messages/GetAllMessagesForEndpointApi.cs +++ b/src/ServiceControl/CompositeViews/Messages/GetAllMessagesForEndpointApi.cs @@ -3,6 +3,7 @@ namespace ServiceControl.CompositeViews.Messages using System.Collections.Generic; using System.Net.Http; using System.Threading.Tasks; + using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Persistence; using Persistence.Infrastructure; @@ -18,8 +19,8 @@ public record AllMessagesForEndpointContext( public class GetAllMessagesForEndpointApi : ScatterGatherApiMessageView { - public GetAllMessagesForEndpointApi(IErrorMessageDataStore dataStore, Settings settings, IHttpClientFactory httpClientFactory, ILogger logger) - : base(dataStore, settings, httpClientFactory, logger) + public GetAllMessagesForEndpointApi(IErrorMessageDataStore dataStore, Settings settings, IHttpClientFactory httpClientFactory, IHttpContextAccessor httpContextAccessor, ILogger logger) + : base(dataStore, settings, httpClientFactory, httpContextAccessor, logger) { } diff --git a/src/ServiceControl/CompositeViews/Messages/MessagesByConversationApi.cs b/src/ServiceControl/CompositeViews/Messages/MessagesByConversationApi.cs index 90837d3439..9848dbfb74 100644 --- a/src/ServiceControl/CompositeViews/Messages/MessagesByConversationApi.cs +++ b/src/ServiceControl/CompositeViews/Messages/MessagesByConversationApi.cs @@ -3,6 +3,7 @@ namespace ServiceControl.CompositeViews.Messages using System.Collections.Generic; using System.Net.Http; using System.Threading.Tasks; + using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Persistence; using Persistence.Infrastructure; @@ -17,8 +18,8 @@ public record MessagesByConversationContext( public class MessagesByConversationApi : ScatterGatherApiMessageView { - public MessagesByConversationApi(IErrorMessageDataStore dataStore, Settings settings, IHttpClientFactory httpClientFactory, ILogger logger) - : base(dataStore, settings, httpClientFactory, logger) + public MessagesByConversationApi(IErrorMessageDataStore dataStore, Settings settings, IHttpClientFactory httpClientFactory, IHttpContextAccessor httpContextAccessor, ILogger logger) + : base(dataStore, settings, httpClientFactory, httpContextAccessor, logger) { } diff --git a/src/ServiceControl/CompositeViews/Messages/ScatterGatherApi.cs b/src/ServiceControl/CompositeViews/Messages/ScatterGatherApi.cs index d9fc9cb535..1268419a0a 100644 --- a/src/ServiceControl/CompositeViews/Messages/ScatterGatherApi.cs +++ b/src/ServiceControl/CompositeViews/Messages/ScatterGatherApi.cs @@ -7,6 +7,7 @@ namespace ServiceControl.CompositeViews.Messages using System.Net.Http; using System.Threading.Tasks; using Infrastructure.WebApi; + using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Persistence.Infrastructure; using ServiceBus.Management.Infrastructure.Settings; @@ -27,26 +28,31 @@ public abstract class ScatterGatherApi : ScatterGatherApi where TIn : ScatterGatherContext where TOut : class { - protected ScatterGatherApi(TDataStore store, Settings settings, IHttpClientFactory httpClientFactory, ILogger logger) + protected ScatterGatherApi(TDataStore store, Settings settings, IHttpClientFactory httpClientFactory, IHttpContextAccessor httpContextAccessor, ILogger logger) { DataStore = store; Settings = settings; HttpClientFactory = httpClientFactory; + HttpContextAccessor = httpContextAccessor; this.logger = logger; } protected TDataStore DataStore { get; } Settings Settings { get; } IHttpClientFactory HttpClientFactory { get; } + IHttpContextAccessor HttpContextAccessor { get; } public async Task> Execute(TIn input, string pathAndQuery) { var remotes = Settings.RemoteInstances; var instanceId = Settings.InstanceId; + var authorizationHeader = HttpContextAccessor.HttpContext?.Request.Headers.Authorization.ToString(); + var tasks = new List>>(remotes.Length + 1) { LocalCall(input, instanceId) }; + foreach (var remote in remotes) { if (remote.TemporarilyUnavailable) @@ -54,7 +60,7 @@ public async Task> Execute(TIn input, string pathAndQuery) continue; } - tasks.Add(RemoteCall(HttpClientFactory.CreateClient(remote.InstanceId), pathAndQuery, remote)); + tasks.Add(RemoteCall(HttpClientFactory.CreateClient(remote.InstanceId), pathAndQuery, remote, authorizationHeader)); } var results = await Task.WhenAll(tasks); @@ -96,19 +102,27 @@ protected virtual QueryStatsInfo AggregateStats(TIn input, IEnumerable> RemoteCall(HttpClient client, string pathAndQuery, RemoteInstanceSetting remoteInstanceSetting) + async Task> RemoteCall(HttpClient client, string pathAndQuery, RemoteInstanceSetting remoteInstanceSetting, string authorizationHeader) { - var fetched = await FetchAndParse(client, pathAndQuery, remoteInstanceSetting); + var fetched = await FetchAndParse(client, pathAndQuery, remoteInstanceSetting, authorizationHeader); fetched.InstanceId = remoteInstanceSetting.InstanceId; return fetched; } - async Task> FetchAndParse(HttpClient httpClient, string pathAndQuery, RemoteInstanceSetting remoteInstanceSetting) + async Task> FetchAndParse(HttpClient httpClient, string pathAndQuery, RemoteInstanceSetting remoteInstanceSetting, string authorizationHeader) { try { + var request = new HttpRequestMessage(HttpMethod.Get, pathAndQuery); + + // Add Authorization header if present + if (!string.IsNullOrEmpty(authorizationHeader)) + { + request.Headers.TryAddWithoutValidation("Authorization", authorizationHeader); + } + // Assuming SendAsync returns uncompressed response and the AutomaticDecompression is enabled on the http client. - var rawResponse = await httpClient.SendAsync(new HttpRequestMessage(HttpMethod.Get, pathAndQuery)); + var rawResponse = await httpClient.SendAsync(request); // special case - queried by conversation ID and nothing was found if (rawResponse.StatusCode == HttpStatusCode.NotFound) { diff --git a/src/ServiceControl/CompositeViews/Messages/ScatterGatherApiMessageView.cs b/src/ServiceControl/CompositeViews/Messages/ScatterGatherApiMessageView.cs index 667338cb28..9f0b1a579e 100644 --- a/src/ServiceControl/CompositeViews/Messages/ScatterGatherApiMessageView.cs +++ b/src/ServiceControl/CompositeViews/Messages/ScatterGatherApiMessageView.cs @@ -3,6 +3,7 @@ namespace ServiceControl.CompositeViews.Messages using System.Collections.Generic; using System.Linq; using System.Net.Http; + using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Persistence.Infrastructure; using ServiceBus.Management.Infrastructure.Settings; @@ -18,8 +19,8 @@ public record ScatterGatherApiMessageViewContext(PagingInfo PagingInfo, SortInfo public abstract class ScatterGatherApiMessageView : ScatterGatherApi> where TInput : ScatterGatherApiMessageViewContext { - protected ScatterGatherApiMessageView(TDataStore dataStore, Settings settings, IHttpClientFactory httpClientFactory, ILogger logger) - : base(dataStore, settings, httpClientFactory, logger) + protected ScatterGatherApiMessageView(TDataStore dataStore, Settings settings, IHttpClientFactory httpClientFactory, IHttpContextAccessor httpContextAccessor, ILogger logger) + : base(dataStore, settings, httpClientFactory, httpContextAccessor, logger) { } diff --git a/src/ServiceControl/CompositeViews/Messages/ScatterGatherRemoteOnly.cs b/src/ServiceControl/CompositeViews/Messages/ScatterGatherRemoteOnly.cs index 3394ceb49e..aa7b62c6dc 100644 --- a/src/ServiceControl/CompositeViews/Messages/ScatterGatherRemoteOnly.cs +++ b/src/ServiceControl/CompositeViews/Messages/ScatterGatherRemoteOnly.cs @@ -2,12 +2,13 @@ namespace ServiceControl.CompositeViews.Messages { using System.Net.Http; using System.Threading.Tasks; + using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Persistence.Infrastructure; using ServiceBus.Management.Infrastructure.Settings; - public abstract class ScatterGatherRemoteOnly(Settings settings, IHttpClientFactory httpClientFactory, ILogger logger) - : ScatterGatherApi(NoOpStore.Instance, settings, httpClientFactory, logger) + public abstract class ScatterGatherRemoteOnly(Settings settings, IHttpClientFactory httpClientFactory, IHttpContextAccessor httpContextAccessor, ILogger logger) + : ScatterGatherApi(NoOpStore.Instance, settings, httpClientFactory, httpContextAccessor, logger) where TIn : ScatterGatherContext where TOut : class { diff --git a/src/ServiceControl/CompositeViews/Messages/SearchApi.cs b/src/ServiceControl/CompositeViews/Messages/SearchApi.cs index 462879cfa5..51ad8798cb 100644 --- a/src/ServiceControl/CompositeViews/Messages/SearchApi.cs +++ b/src/ServiceControl/CompositeViews/Messages/SearchApi.cs @@ -3,6 +3,7 @@ namespace ServiceControl.CompositeViews.Messages using System.Collections.Generic; using System.Net.Http; using System.Threading.Tasks; + using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Persistence; using Persistence.Infrastructure; @@ -17,8 +18,8 @@ public record SearchApiContext( public class SearchApi : ScatterGatherApiMessageView { - public SearchApi(IErrorMessageDataStore dataStore, Settings settings, IHttpClientFactory httpClientFactory, ILogger logger) - : base(dataStore, settings, httpClientFactory, logger) + public SearchApi(IErrorMessageDataStore dataStore, Settings settings, IHttpClientFactory httpClientFactory, IHttpContextAccessor httpContextAccessor, ILogger logger) + : base(dataStore, settings, httpClientFactory, httpContextAccessor, logger) { } diff --git a/src/ServiceControl/CompositeViews/Messages/SearchEndpointApi.cs b/src/ServiceControl/CompositeViews/Messages/SearchEndpointApi.cs index 11444b8734..897ce47e4e 100644 --- a/src/ServiceControl/CompositeViews/Messages/SearchEndpointApi.cs +++ b/src/ServiceControl/CompositeViews/Messages/SearchEndpointApi.cs @@ -3,6 +3,7 @@ namespace ServiceControl.CompositeViews.Messages using System.Collections.Generic; using System.Net.Http; using System.Threading.Tasks; + using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Persistence; using Persistence.Infrastructure; @@ -18,8 +19,8 @@ public record SearchEndpointContext( public class SearchEndpointApi : ScatterGatherApiMessageView { - public SearchEndpointApi(IErrorMessageDataStore dataStore, Settings settings, IHttpClientFactory httpClientFactory, ILogger logger) - : base(dataStore, settings, httpClientFactory, logger) + public SearchEndpointApi(IErrorMessageDataStore dataStore, Settings settings, IHttpClientFactory httpClientFactory, IHttpContextAccessor httpContextAccessor, ILogger logger) + : base(dataStore, settings, httpClientFactory, httpContextAccessor, logger) { } diff --git a/src/ServiceControl/Monitoring/Web/GetKnownEndpointsApi.cs b/src/ServiceControl/Monitoring/Web/GetKnownEndpointsApi.cs index 1beea8d85f..d86a4c4f48 100644 --- a/src/ServiceControl/Monitoring/Web/GetKnownEndpointsApi.cs +++ b/src/ServiceControl/Monitoring/Web/GetKnownEndpointsApi.cs @@ -5,6 +5,7 @@ using System.Net.Http; using System.Threading.Tasks; using CompositeViews.Messages; + using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Persistence; using Persistence.Infrastructure; @@ -14,8 +15,9 @@ public class GetKnownEndpointsApi( IEndpointInstanceMonitoring store, Settings settings, IHttpClientFactory httpClientFactory, + IHttpContextAccessor httpContextAccessor, ILogger logger) - : ScatterGatherApi>(store, settings, httpClientFactory, logger) + : ScatterGatherApi>(store, settings, httpClientFactory, httpContextAccessor, logger) { protected override Task>> LocalQuery(ScatterGatherContext input) { diff --git a/src/ServiceControl/SagaAudit/GetSagaByIdApi.cs b/src/ServiceControl/SagaAudit/GetSagaByIdApi.cs index 61dfaef988..559fa1f143 100644 --- a/src/ServiceControl/SagaAudit/GetSagaByIdApi.cs +++ b/src/ServiceControl/SagaAudit/GetSagaByIdApi.cs @@ -4,14 +4,15 @@ namespace ServiceControl.SagaAudit using System.Linq; using System.Net.Http; using CompositeViews.Messages; + using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Persistence.Infrastructure; using ServiceBus.Management.Infrastructure.Settings; public record SagaByIdContext(PagingInfo PagingInfo, Guid SagaId) : ScatterGatherContext(PagingInfo); - public class GetSagaByIdApi(Settings settings, IHttpClientFactory httpClientFactory, ILogger logger) - : ScatterGatherRemoteOnly(settings, httpClientFactory, logger) + public class GetSagaByIdApi(Settings settings, IHttpClientFactory httpClientFactory, IHttpContextAccessor httpContextAccessor, ILogger logger) + : ScatterGatherRemoteOnly(settings, httpClientFactory, httpContextAccessor, logger) { protected override SagaHistory ProcessResults(SagaByIdContext input, QueryResult[] results) { From 5e860402462f6d5d988354e8a1b865329c6234ce Mon Sep 17 00:00:00 2001 From: jasontaylordev Date: Fri, 5 Dec 2025 11:35:13 +1000 Subject: [PATCH 13/24] Allow Anon for CheckRemotes --- .../Infrastructure/WebApi/RootController.cs | 2 ++ src/ServiceControl.Monitoring/Http/RootController.cs | 2 ++ src/ServiceControl/Infrastructure/WebApi/RootController.cs | 2 ++ 3 files changed, 6 insertions(+) diff --git a/src/ServiceControl.Audit/Infrastructure/WebApi/RootController.cs b/src/ServiceControl.Audit/Infrastructure/WebApi/RootController.cs index 064122234d..36ce8d8363 100644 --- a/src/ServiceControl.Audit/Infrastructure/WebApi/RootController.cs +++ b/src/ServiceControl.Audit/Infrastructure/WebApi/RootController.cs @@ -1,6 +1,7 @@ namespace ServiceControl.Audit.Infrastructure.WebApi { using Configuration; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Mvc; using Settings; @@ -16,6 +17,7 @@ public RootController(Settings settings) [Route("")] [HttpGet] + [AllowAnonymous] public OkObjectResult Urls() { var baseUrl = Request.GetDisplayUrl(); diff --git a/src/ServiceControl.Monitoring/Http/RootController.cs b/src/ServiceControl.Monitoring/Http/RootController.cs index c22f7f7ff1..fe15583ab0 100644 --- a/src/ServiceControl.Monitoring/Http/RootController.cs +++ b/src/ServiceControl.Monitoring/Http/RootController.cs @@ -1,5 +1,6 @@ namespace ServiceControl.Monitoring.Http { + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Primitives; @@ -8,6 +9,7 @@ public class RootController : ControllerBase { [Route("")] [HttpGet] + [AllowAnonymous] public ActionResult Get() { var model = new MonitoringInstanceModel diff --git a/src/ServiceControl/Infrastructure/WebApi/RootController.cs b/src/ServiceControl/Infrastructure/WebApi/RootController.cs index f86b400be1..b7500d44d7 100644 --- a/src/ServiceControl/Infrastructure/WebApi/RootController.cs +++ b/src/ServiceControl/Infrastructure/WebApi/RootController.cs @@ -2,6 +2,7 @@ { using System.Threading; using System.Threading.Tasks; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Mvc; using ServiceControl.Api; @@ -13,6 +14,7 @@ public class RootController(IConfigurationApi configurationApi) : ControllerBase { [Route("")] [HttpGet] + [AllowAnonymous] public Task Urls() => configurationApi.GetUrls(Request.GetDisplayUrl(), default); [Route("instance-info")] From 754246a6e36f215557927004d756a11586d7f9a3 Mon Sep 17 00:00:00 2001 From: jasontaylordev Date: Fri, 5 Dec 2025 12:02:42 +1000 Subject: [PATCH 14/24] Remove unused rate limiting middleware --- src/ServiceControl/WebApplicationExtensions.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/ServiceControl/WebApplicationExtensions.cs b/src/ServiceControl/WebApplicationExtensions.cs index 15f59a552b..1e0d6b2909 100644 --- a/src/ServiceControl/WebApplicationExtensions.cs +++ b/src/ServiceControl/WebApplicationExtensions.cs @@ -16,7 +16,6 @@ public static void UseServiceControl(this WebApplication app, ForwardedHeadersSe app.UseResponseCompression(); app.UseMiddleware(); app.UseHttpLogging(); - app.UseRateLimiter(); app.MapHub("/api/messagestream"); app.UseCors(); } From 0c1faa790dfb5ec28eca2b5d769c8d0eb2ad28c1 Mon Sep 17 00:00:00 2001 From: jasontaylordev Date: Fri, 5 Dec 2025 14:21:13 +1000 Subject: [PATCH 15/24] MapControllers correctly --- .../Auth/HostApplicationBuilderExtensions.cs | 5 +++++ .../Auth/WebApplicationExtensions.cs | 13 +++++-------- src/ServiceControl/WebApplicationExtensions.cs | 1 + 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/ServiceControl.Hosting/Auth/HostApplicationBuilderExtensions.cs b/src/ServiceControl.Hosting/Auth/HostApplicationBuilderExtensions.cs index e6e542b306..b3e4b32c97 100644 --- a/src/ServiceControl.Hosting/Auth/HostApplicationBuilderExtensions.cs +++ b/src/ServiceControl.Hosting/Auth/HostApplicationBuilderExtensions.cs @@ -93,6 +93,11 @@ public static void AddServiceControlAuthentication(this IHostApplicationBuilder } }; }); + + hostBuilder.Services.AddAuthorization(configure => + configure.FallbackPolicy = new Microsoft.AspNetCore.Authorization.AuthorizationPolicyBuilder() + .RequireAuthenticatedUser() + .Build()); } static string GetErrorMessage(JwtBearerChallengeContext context) diff --git a/src/ServiceControl.Hosting/Auth/WebApplicationExtensions.cs b/src/ServiceControl.Hosting/Auth/WebApplicationExtensions.cs index c78b83a7b6..68005c40b1 100644 --- a/src/ServiceControl.Hosting/Auth/WebApplicationExtensions.cs +++ b/src/ServiceControl.Hosting/Auth/WebApplicationExtensions.cs @@ -6,16 +6,13 @@ public static class WebApplicationExtensions { public static void UseServiceControlAuthentication(this WebApplication app, bool authenticationEnabled = false) { - if (authenticationEnabled) + if (!authenticationEnabled) { - app.UseAuthentication(); - app.UseAuthorization(); - app.MapControllers().RequireAuthorization(); - } - else - { - app.MapControllers(); + return; } + + app.UseAuthentication(); + app.UseAuthorization(); } } } diff --git a/src/ServiceControl/WebApplicationExtensions.cs b/src/ServiceControl/WebApplicationExtensions.cs index 1e0d6b2909..685bc7dc16 100644 --- a/src/ServiceControl/WebApplicationExtensions.cs +++ b/src/ServiceControl/WebApplicationExtensions.cs @@ -18,5 +18,6 @@ public static void UseServiceControl(this WebApplication app, ForwardedHeadersSe app.UseHttpLogging(); app.MapHub("/api/messagestream"); app.UseCors(); + app.MapControllers(); } } \ No newline at end of file From a5d9d5b19045ec3f47457442f978b2d831a8f119 Mon Sep 17 00:00:00 2001 From: jasontaylordev Date: Mon, 8 Dec 2025 11:11:33 +1000 Subject: [PATCH 16/24] Upgrade package --- src/Directory.Packages.props | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 5da0825705..d29f860a49 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -7,7 +7,7 @@ - + @@ -88,6 +88,7 @@ + From a1b043b174e8588a506463a74266090b2558e5ba Mon Sep 17 00:00:00 2001 From: Warwick Schroeder Date: Fri, 12 Dec 2025 15:39:23 +0800 Subject: [PATCH 17/24] Update local testing files. Add debug endpoint for dev. --- docs/local-forward-headers-testing.md | 726 ++++++++++++++++++ docs/local-https-testing.md | 228 ++++-- ...sting.md => local-reverseproxy-testing.md} | 162 +++- .../WebApplicationExtensions.cs | 41 + .../ForwardedHeadersSettings.cs | 11 - .../HttpsSettings.cs | 30 +- src/ServiceControl/App.config | 6 +- .../Properties/launchSettings.json | 1 - 8 files changed, 1077 insertions(+), 128 deletions(-) create mode 100644 docs/local-forward-headers-testing.md rename docs/{local-nginx-testing.md => local-reverseproxy-testing.md} (63%) diff --git a/docs/local-forward-headers-testing.md b/docs/local-forward-headers-testing.md new file mode 100644 index 0000000000..df18efc5a7 --- /dev/null +++ b/docs/local-forward-headers-testing.md @@ -0,0 +1,726 @@ +# Local Testing Forwarded Headers (Without NGINX) + +This guide explains how to test forwarded headers configuration for ServiceControl instances without using NGINX or Docker. This approach uses curl to manually send `X-Forwarded-*` headers directly to the instances. + +## Prerequisites + +- ServiceControl built locally (see main README for build instructions) +- curl (included with Windows 10/11, Git Bash, or WSL) +- (Optional) For formatted JSON output: `npm install -g json` then pipe curl output through `| json` +- All commands assume you are in the respective project directory + +## Instance Reference + +| Instance | Project Directory | Default Port | Environment Variable Prefix | +|----------|-------------------|--------------|----------------------------| +| ServiceControl (Primary) | `src\ServiceControl` | 33333 | `SERVICECONTROL_` | +| ServiceControl.Audit | `src\ServiceControl.Audit` | 44444 | `SERVICECONTROL_AUDIT_` | +| ServiceControl.Monitoring | `src\ServiceControl.Monitoring` | 33633 | `MONITORING_` | + +> **Note:** Environment variables must include the instance prefix (e.g., `SERVICECONTROL_FORWARDEDHEADERS_ENABLED` for the primary instance). + +## How Forwarded Headers Work + +When a ServiceControl instance is behind a reverse proxy, the proxy sends headers to indicate the original request details: + +- `X-Forwarded-For` - Original client IP address +- `X-Forwarded-Proto` - Original protocol (http/https) +- `X-Forwarded-Host` - Original host header + +Each instance can be configured to trust these headers from specific proxies or trust all proxies. + +### Trust Evaluation Rules + +The middleware determines whether to process forwarded headers based on these rules: + +1. **If `TrustAllProxies` = true**: All requests are trusted, headers are always processed +2. **If `TrustAllProxies` = false**: The caller's IP must match **either**: + - **KnownProxies**: Exact IP address match (e.g., `127.0.0.1`, `::1`) + - **KnownNetworks**: CIDR range match (e.g., `127.0.0.0/8`, `10.0.0.0/8`) + +> **Important:** KnownProxies and KnownNetworks use **OR logic** - a match in either grants trust. The check is against the **immediate caller's IP** (the proxy connecting to ServiceControl), not the original client IP from `X-Forwarded-For`. + +## Configuration Methods + +Settings can be configured via: + +1. **Environment variables** (recommended for testing) - Easy to change between scenarios, no file edits needed +2. **App.config** - Persisted settings, requires app restart after changes + +Both methods work identically. This guide uses environment variables for convenience during iterative testing. + +## Test Scenarios + +The following scenarios use ServiceControl (Primary) as an example. To test other instances: + +1. Navigate to the instance's project directory +2. Use the instance's default port in the curl commands +3. Optionally use the instance-specific environment variable prefix + +> **Important:** Set environment variables in the same terminal where you run `dotnet run`. Environment variables are scoped to the terminal session and won't be seen if you run from Visual Studio or a different terminal. +> +> **Tip:** Check the application startup logs to verify which settings were applied. The forwarded headers configuration is logged at startup. + +### Scenario 0: Direct Access (No Proxy) + +Test a direct request without any forwarded headers, simulating access without a reverse proxy. + +**Cleanup and start ServiceControl:** + +```cmd +set SERVICECONTROL_FORWARDEDHEADERS_ENABLED= +set SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES= +set SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES= +set SERVICECONTROL_FORWARDEDHEADERS_KNOWNNETWORKS= + +dotnet run +``` + +**Test with curl (no forwarded headers):** + +```cmd +curl http://localhost:33333/debug/request-info | json +``` + +**Expected output:** + +```json +{ + "processed": { + "scheme": "http", + "host": "localhost:33333", + "remoteIpAddress": "::1" + }, + "rawHeaders": { + "xForwardedFor": "", + "xForwardedProto": "", + "xForwardedHost": "" + }, + "configuration": { + "enabled": true, + "trustAllProxies": true, + "knownProxies": [], + "knownNetworks": [] + } +} +``` + +When no forwarded headers are sent, the request values remain unchanged. + +### Scenario 1: Default Behavior (With Headers) + +Test the default behavior when no forwarded headers environment variables are set, but headers are sent. + +**Cleanup and start ServiceControl:** + +```cmd +set SERVICECONTROL_FORWARDEDHEADERS_ENABLED= +set SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES= +set SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES= +set SERVICECONTROL_FORWARDEDHEADERS_KNOWNNETWORKS= + +dotnet run +``` + +**Test with curl:** + +```cmd +curl -H "X-Forwarded-Proto: https" -H "X-Forwarded-Host: example.com" -H "X-Forwarded-For: 203.0.113.50" http://localhost:33333/debug/request-info | json +``` + +**Expected output:** + +```json +{ + "processed": { + "scheme": "https", + "host": "example.com", + "remoteIpAddress": "203.0.113.50" + }, + "rawHeaders": { + "xForwardedFor": "", + "xForwardedProto": "", + "xForwardedHost": "" + }, + "configuration": { + "enabled": true, + "trustAllProxies": true, + "knownProxies": [], + "knownNetworks": [] + } +} +``` + +By default, forwarded headers are **enabled** and **all proxies are trusted**. This means any client can spoof `X-Forwarded-*` headers. This is suitable for development but should be restricted in production by configuring `KnownProxies` or `KnownNetworks`. + +### Scenario 2: Trust All Proxies (Explicit) + +Explicitly enable trust all proxies (same as default, but explicit configuration). + +**Cleanup and start ServiceControl:** + +```cmd +set SERVICECONTROL_FORWARDEDHEADERS_ENABLED=true +set SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES=true +set SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES= +set SERVICECONTROL_FORWARDEDHEADERS_KNOWNNETWORKS= + +dotnet run +``` + +**Test with curl:** + +```cmd +curl -H "X-Forwarded-Proto: https" -H "X-Forwarded-Host: example.com" -H "X-Forwarded-For: 203.0.113.50" http://localhost:33333/debug/request-info | json +``` + +**Expected output:** + +```json +{ + "processed": { + "scheme": "https", + "host": "example.com", + "remoteIpAddress": "203.0.113.50" + }, + "rawHeaders": { + "xForwardedFor": "", + "xForwardedProto": "", + "xForwardedHost": "" + }, + "configuration": { + "enabled": true, + "trustAllProxies": true, + "knownProxies": [], + "knownNetworks": [] + } +} +``` + +The `scheme` is `https` (from `X-Forwarded-Proto`), `host` is `example.com` (from `X-Forwarded-Host`), and `remoteIpAddress` is `203.0.113.50` (from `X-Forwarded-For`) because all proxies are trusted. The `rawHeaders` are empty because the middleware consumed them. + +### Scenario 3: Known Proxies Only + +Only accept forwarded headers from specific IP addresses. + +**Cleanup and start ServiceControl:** + +```cmd +set SERVICECONTROL_FORWARDEDHEADERS_ENABLED=true +set SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES=127.0.0.1,::1 +set SERVICECONTROL_FORWARDEDHEADERS_KNOWNNETWORKS= + +dotnet run +``` + +> **Note:** Setting `SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES` automatically disables `SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES`. Both IPv4 (`127.0.0.1`) and IPv6 (`::1`) loopback addresses are included since curl may use either. + +**Test with curl (from localhost - should work):** + +```cmd +curl -H "X-Forwarded-Proto: https" -H "X-Forwarded-Host: example.com" -H "X-Forwarded-For: 203.0.113.50" http://localhost:33333/debug/request-info | json +``` + +**Expected output:** + +```json +{ + "processed": { + "scheme": "https", + "host": "example.com", + "remoteIpAddress": "203.0.113.50" + }, + "rawHeaders": { + "xForwardedFor": "", + "xForwardedProto": "", + "xForwardedHost": "" + }, + "configuration": { + "enabled": true, + "trustAllProxies": false, + "knownProxies": ["127.0.0.1", "::1"], + "knownNetworks": [] + } +} +``` + +Headers are applied because the request comes from localhost, which is in the known proxies list. The `rawHeaders` are empty because the middleware consumed them. + +### Scenario 4: Known Networks (CIDR) + +Trust all proxies within a network range. + +**Cleanup and start ServiceControl:** + +```cmd +set SERVICECONTROL_FORWARDEDHEADERS_ENABLED=true +set SERVICECONTROL_FORWARDEDHEADERS_KNOWNNETWORKS=127.0.0.0/8,::1/128 +set SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES= + +dotnet run +``` + +> **Note:** Both IPv4 (`127.0.0.0/8`) and IPv6 (`::1/128`) loopback networks are included since curl may use either. + +**Test with curl:** + +```cmd +curl -H "X-Forwarded-Proto: https" -H "X-Forwarded-Host: example.com" -H "X-Forwarded-For: 203.0.113.50" http://localhost:33333/debug/request-info | json +``` + +**Expected output:** + +```json +{ + "processed": { + "scheme": "https", + "host": "example.com", + "remoteIpAddress": "203.0.113.50" + }, + "rawHeaders": { + "xForwardedFor": "", + "xForwardedProto": "", + "xForwardedHost": "" + }, + "configuration": { + "enabled": true, + "trustAllProxies": false, + "knownProxies": [], + "knownNetworks": ["127.0.0.0/8", "::1/128"] + } +} +``` + +Headers are applied because the request comes from localhost, which falls within the known networks. The `rawHeaders` are empty because the middleware consumed them. + +### Scenario 5: Unknown Proxy Rejected + +Configure a known proxy that doesn't match the request source to verify headers are ignored. + +**Cleanup and start ServiceControl:** + +```cmd +set SERVICECONTROL_FORWARDEDHEADERS_ENABLED=true +set SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES= +set SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES=192.168.1.100 +set SERVICECONTROL_FORWARDEDHEADERS_KNOWNNETWORKS= + +dotnet run +``` + +**Test with curl:** + +```cmd +curl -H "X-Forwarded-Proto: https" -H "X-Forwarded-Host: example.com" -H "X-Forwarded-For: 203.0.113.50" http://localhost:33333/debug/request-info | json +``` + +**Expected output:** + +```json +{ + "processed": { + "scheme": "http", + "host": "localhost:33333", + "remoteIpAddress": "::1" + }, + "rawHeaders": { + "xForwardedFor": "203.0.113.50", + "xForwardedProto": "https", + "xForwardedHost": "example.com" + }, + "configuration": { + "enabled": true, + "trustAllProxies": false, + "knownProxies": ["192.168.1.100"], + "knownNetworks": [] + } +} +``` + +Headers are **ignored** because the request comes from localhost (`::1`), which is NOT in the known proxies list (`192.168.1.100`). Notice `scheme` is `http` (unchanged from original request). The `rawHeaders` still show the headers that were sent but not applied. + +### Scenario 6: Unknown Network Rejected + +Configure a known network that doesn't match the request source to verify headers are ignored. + +**Cleanup and start ServiceControl:** + +```cmd +set SERVICECONTROL_FORWARDEDHEADERS_ENABLED=true +set SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES= +set SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES= +set SERVICECONTROL_FORWARDEDHEADERS_KNOWNNETWORKS=10.0.0.0/8,192.168.0.0/16 + +dotnet run +``` + +**Test with curl:** + +```cmd +curl -H "X-Forwarded-Proto: https" -H "X-Forwarded-Host: example.com" -H "X-Forwarded-For: 203.0.113.50" http://localhost:33333/debug/request-info | json +``` + +**Expected output:** + +```json +{ + "processed": { + "scheme": "http", + "host": "localhost:33333", + "remoteIpAddress": "::1" + }, + "rawHeaders": { + "xForwardedFor": "203.0.113.50", + "xForwardedProto": "https", + "xForwardedHost": "example.com" + }, + "configuration": { + "enabled": true, + "trustAllProxies": false, + "knownProxies": [], + "knownNetworks": ["10.0.0.0/8", "192.168.0.0/16"] + } +} +``` + +Headers are **ignored** because the request comes from localhost (`::1`), which is NOT in the known networks (`10.0.0.0/8` or `192.168.0.0/16`). Notice `scheme` is `http` (unchanged from original request). The `rawHeaders` still show the headers that were sent but not applied. + +### Scenario 7: Forwarded Headers Disabled + +Completely disable forwarded headers processing. + +**Cleanup and start ServiceControl:** + +```cmd +set SERVICECONTROL_FORWARDEDHEADERS_ENABLED=false +set SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES= +set SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES= +set SERVICECONTROL_FORWARDEDHEADERS_KNOWNNETWORKS= + +dotnet run +``` + +**Test with curl:** + +```cmd +curl -H "X-Forwarded-Proto: https" -H "X-Forwarded-Host: example.com" -H "X-Forwarded-For: 203.0.113.50" http://localhost:33333/debug/request-info | json +``` + +**Expected output:** + +```json +{ + "processed": { + "scheme": "http", + "host": "localhost:33333", + "remoteIpAddress": "::1" + }, + "rawHeaders": { + "xForwardedFor": "203.0.113.50", + "xForwardedProto": "https", + "xForwardedHost": "example.com" + }, + "configuration": { + "enabled": false, + "trustAllProxies": false, + "knownProxies": [], + "knownNetworks": [] + } +} +``` + +Headers are ignored because forwarded headers processing is disabled entirely. Notice `enabled` is `false` in the configuration. + +### Scenario 8: Proxy Chain (Multiple X-Forwarded-For Values) + +Test how ServiceControl handles multiple proxies in the chain. + +**Cleanup and start ServiceControl:** + +```cmd +set SERVICECONTROL_FORWARDEDHEADERS_ENABLED=true +set SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES=true +set SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES= +set SERVICECONTROL_FORWARDEDHEADERS_KNOWNNETWORKS= + +dotnet run +``` + +**Test with curl (simulating a proxy chain):** + +```cmd +curl -H "X-Forwarded-Proto: https" -H "X-Forwarded-Host: example.com" -H "X-Forwarded-For: 203.0.113.50, 10.0.0.1, 192.168.1.1" http://localhost:33333/debug/request-info | json +``` + +**Expected output:** + +```json +{ + "processed": { + "scheme": "https", + "host": "example.com", + "remoteIpAddress": "192.168.1.1" + }, + "rawHeaders": { + "xForwardedFor": "", + "xForwardedProto": "", + "xForwardedHost": "" + }, + "configuration": { + "enabled": true, + "trustAllProxies": true, + "knownProxies": [], + "knownNetworks": [] + } +} +``` + +The `X-Forwarded-For` header contains multiple IPs representing the proxy chain. By default, ASP.NET Core's `ForwardLimit` is `1`, so only the last proxy IP is used. + +### Scenario 9: Combined Known Proxies and Networks + +Test using both `KnownProxies` and `KnownNetworks` together. + +**Cleanup and start ServiceControl:** + +```cmd +set SERVICECONTROL_FORWARDEDHEADERS_ENABLED=true +set SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES= +set SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES=192.168.1.100 +set SERVICECONTROL_FORWARDEDHEADERS_KNOWNNETWORKS=127.0.0.0/8,::1/128 + +dotnet run +``` + +**Test with curl:** + +```cmd +curl -H "X-Forwarded-Proto: https" -H "X-Forwarded-Host: example.com" -H "X-Forwarded-For: 203.0.113.50" http://localhost:33333/debug/request-info | json +``` + +**Expected output:** + +```json +{ + "processed": { + "scheme": "https", + "host": "example.com", + "remoteIpAddress": "203.0.113.50" + }, + "rawHeaders": { + "xForwardedFor": "", + "xForwardedProto": "", + "xForwardedHost": "" + }, + "configuration": { + "enabled": true, + "trustAllProxies": false, + "knownProxies": ["192.168.1.100"], + "knownNetworks": ["127.0.0.0/8", "::1/128"] + } +} +``` + +Headers are applied because the request comes from localhost (`::1`), which falls within the `::1/128` network even though it's not in the `knownProxies` list. + +### Scenario 10: Partial Headers (Proto Only) + +Test that each forwarded header is processed independently. Only sending `X-Forwarded-Proto` should update the scheme while leaving host and remoteIpAddress unchanged. + +**Cleanup and start ServiceControl:** + +```cmd +set SERVICECONTROL_FORWARDEDHEADERS_ENABLED=true +set SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES=true +set SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES= +set SERVICECONTROL_FORWARDEDHEADERS_KNOWNNETWORKS= + +dotnet run +``` + +**Test with curl (only X-Forwarded-Proto):** + +```cmd +curl -H "X-Forwarded-Proto: https" http://localhost:33333/debug/request-info | json +``` + +**Expected output:** + +```json +{ + "processed": { + "scheme": "https", + "host": "localhost:33333", + "remoteIpAddress": "::1" + }, + "rawHeaders": { + "xForwardedFor": "", + "xForwardedProto": "", + "xForwardedHost": "" + }, + "configuration": { + "enabled": true, + "trustAllProxies": true, + "knownProxies": [], + "knownNetworks": [] + } +} +``` + +Only the `scheme` changed to `https`. The `host` remains `localhost:33333` and `remoteIpAddress` remains `::1` because those headers weren't sent. Each header is processed independently. + +### Scenario 11: IPv4/IPv6 Mismatch + +Demonstrates a common misconfiguration where only IPv4 localhost is configured but curl uses IPv6. This scenario shows why you should include both `127.0.0.1` and `::1` in your configuration. + +**Cleanup and start ServiceControl:** + +```cmd +set SERVICECONTROL_FORWARDEDHEADERS_ENABLED=true +set SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES= +set SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES=127.0.0.1 +set SERVICECONTROL_FORWARDEDHEADERS_KNOWNNETWORKS= + +dotnet run +``` + +> **Note:** Only IPv4 `127.0.0.1` is configured, not IPv6 `::1`. + +**Test with curl:** + +```cmd +curl -H "X-Forwarded-Proto: https" -H "X-Forwarded-Host: example.com" -H "X-Forwarded-For: 203.0.113.50" http://localhost:33333/debug/request-info | json +``` + +**Expected output (if curl uses IPv6):** + +```json +{ + "processed": { + "scheme": "http", + "host": "localhost:33333", + "remoteIpAddress": "::1" + }, + "rawHeaders": { + "xForwardedFor": "203.0.113.50", + "xForwardedProto": "https", + "xForwardedHost": "example.com" + }, + "configuration": { + "enabled": true, + "trustAllProxies": false, + "knownProxies": ["127.0.0.1"], + "knownNetworks": [] + } +} +``` + +Headers are **ignored** because the request comes from `::1` (IPv6), but only `127.0.0.1` (IPv4) is in the known proxies list. This is a common gotcha - always include both IPv4 and IPv6 loopback addresses when testing locally, or use CIDR notation like `127.0.0.0/8` and `::1/128`. + +> **Tip:** If your output shows headers were applied, curl is using IPv4. The behavior depends on your system's DNS resolution for `localhost`. + +## Debug Endpoint + +The `/debug/request-info` endpoint is only available in Development environment. It returns: + +```json +{ + "processed": { + "scheme": "https", + "host": "example.com", + "remoteIpAddress": "203.0.113.50" + }, + "rawHeaders": { + "xForwardedFor": "", + "xForwardedProto": "", + "xForwardedHost": "" + }, + "configuration": { + "enabled": true, + "trustAllProxies": false, + "knownProxies": ["127.0.0.1"], + "knownNetworks": [] + } +} +``` + +| Section | Field | Description | +|---------|-------|-------------| +| `processed` | `scheme` | The request scheme after forwarded headers processing | +| `processed` | `host` | The request host after forwarded headers processing | +| `processed` | `remoteIpAddress` | The client IP after forwarded headers processing | +| `rawHeaders` | `xForwardedFor` | Raw `X-Forwarded-For` header (empty if consumed by middleware) | +| `rawHeaders` | `xForwardedProto` | Raw `X-Forwarded-Proto` header (empty if consumed by middleware) | +| `rawHeaders` | `xForwardedHost` | Raw `X-Forwarded-Host` header (empty if consumed by middleware) | +| `configuration` | `enabled` | Whether forwarded headers middleware is enabled | +| `configuration` | `trustAllProxies` | Whether all proxies are trusted (security warning if true) | +| `configuration` | `knownProxies` | List of trusted proxy IP addresses | +| `configuration` | `knownNetworks` | List of trusted CIDR network ranges | + +### Key Diagnostic Questions + +1. **Were headers applied?** - If `rawHeaders` are empty but `processed` values changed, the middleware consumed and applied them +2. **Why weren't headers applied?** - If `rawHeaders` still contain values, the middleware didn't trust the caller. Check `knownProxies` and `knownNetworks` in `configuration` +3. **Is forwarded headers enabled?** - Check `configuration.enabled` + +## Cleanup + +After testing, clear the environment variables: + +**Command Prompt (cmd):** + +```cmd +set SERVICECONTROL_FORWARDEDHEADERS_ENABLED= +set SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES= +set SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES= +set SERVICECONTROL_FORWARDEDHEADERS_KNOWNNETWORKS= +``` + +**PowerShell:** + +```powershell +$env:SERVICECONTROL_FORWARDEDHEADERS_ENABLED = $null +$env:SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES = $null +$env:SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES = $null +$env:SERVICECONTROL_FORWARDEDHEADERS_KNOWNNETWORKS = $null +``` + +**Bash (Git Bash, WSL, Linux, macOS):** + +```bash +unset SERVICECONTROL_FORWARDEDHEADERS_ENABLED +unset SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES +unset SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES +unset SERVICECONTROL_FORWARDEDHEADERS_KNOWNNETWORKS +``` + +## Quick Reference: Testing Other Instances + +### ServiceControl.Audit + +```cmd +cd src\ServiceControl.Audit +set SERVICECONTROL_AUDIT_FORWARDEDHEADERS_KNOWNPROXIES=127.0.0.1,::1 +dotnet run +``` + +```cmd +curl -H "X-Forwarded-Proto: https" http://localhost:44444/debug/request-info | json +``` + +### ServiceControl.Monitoring + +```cmd +cd src\ServiceControl.Monitoring +set MONITORING_FORWARDEDHEADERS_KNOWNPROXIES=127.0.0.1,::1 +dotnet run +``` + +```cmd +curl -H "X-Forwarded-Proto: https" http://localhost:33633/debug/request-info | json +``` + +## See Also + +- [Hosting Guide](hosting-guide.md) - Configuration reference for forwarded headers +- [Local Reverse Proxy Testing](local-reverseproxy-testing.md) - Testing with a real reverse proxy (NGINX) diff --git a/docs/local-https-testing.md b/docs/local-https-testing.md index 00150722c3..fdd30e8318 100644 --- a/docs/local-https-testing.md +++ b/docs/local-https-testing.md @@ -1,16 +1,24 @@ # Local Testing with Direct HTTPS -This guide explains how to test ServiceControl with direct HTTPS enabled on Kestrel, without using a reverse proxy. This is useful for testing scenarios like: +This guide provides scenario-based tests for ServiceControl's direct HTTPS features. Use this to verify Kestrel HTTPS behavior without a reverse proxy. -- Direct TLS termination at ServiceControl -- HTTPS redirection -- HSTS (HTTP Strict Transport Security) -- End-to-end encryption testing +> **Note:** HTTP to HTTPS redirection (`RedirectHttpToHttps`) is designed for reverse proxy scenarios where the proxy forwards HTTP requests to ServiceControl. When running with direct HTTPS, ServiceControl only binds to a single port (HTTPS). To test HTTP to HTTPS redirection, see [Local Reverse Proxy Testing](local-reverseproxy-testing.md). + +## Instance Reference + +| Instance | Project Directory | Default Port | Environment Variable Prefix | App.config Key Prefix | +|----------|-------------------|--------------|-----------------------------|-----------------------| +| ServiceControl (Primary) | `src\ServiceControl` | 33333 | `SERVICECONTROL_` | `ServiceControl/` | +| ServiceControl.Audit | `src\ServiceControl.Audit` | 44444 | `SERVICECONTROL_AUDIT_` | `ServiceControl.Audit/` | +| ServiceControl.Monitoring | `src\ServiceControl.Monitoring` | 33633 | `MONITORING_` | `Monitoring/` | + +> **Note:** Environment variables must include the instance prefix (e.g., `SERVICECONTROL_HTTPS_ENABLED` for the primary instance). ## Prerequisites - [mkcert](https://github.com/FiloSottile/mkcert) for generating local development certificates - ServiceControl built locally (see main README for build instructions) +- curl (included with Windows 10/11, Git Bash, or WSL) ### Installing mkcert @@ -45,7 +53,9 @@ sudo pacman -S mkcert After installing, run `mkcert -install` to install the local CA in your system trust store. -## Step 1: Create the Local Development Folder +## Setup + +### Step 1: Create the Local Development Folder Create a `.local` folder in the repository root (this folder is gitignored): @@ -54,7 +64,7 @@ mkdir .local mkdir .local/certs ``` -## Step 2: Generate PFX Certificates +### Step 2: Generate PFX Certificates Kestrel requires certificates in PFX format. Use mkcert to generate them: @@ -69,107 +79,124 @@ cd .local/certs mkcert -p12-file localhost.pfx -pkcs12 localhost 127.0.0.1 ::1 ``` -When prompted for a password, you can use an empty password by pressing Enter, or set a password and note it for the configuration step. +When prompted for a password, you can use an empty password by pressing Enter, or set a password (e.g., `changeit`) and note it for the configuration step. + +## Test Scenarios -## Step 3: Configure ServiceControl Instances +All scenarios use environment variables for configuration. Run each scenario from the `src/ServiceControl` directory. -Configure HTTPS in the `App.config` file for each ServiceControl instance. See [HTTPS Settings](hosting-guide.md#https-settings) in the Hosting Guide for all available options. +### Scenario 1: Basic HTTPS Connectivity -| Instance | Config Key Prefix | App.config Location | -|----------|-------------------|---------------------| -| ServiceControl (Primary) | `ServiceControl/` | `src/ServiceControl/App.config` | -| ServiceControl.Audit | `ServiceControl.Audit/` | `src/ServiceControl.Audit/App.config` | -| ServiceControl.Monitoring | `Monitoring/` | `src/ServiceControl.Monitoring/App.config` | +Verify that HTTPS is working with a valid certificate. -Example for ServiceControl (Primary): +**Cleanup and start ServiceControl:** -```xml - - - - - +```cmd +set SERVICECONTROL_HTTPS_ENABLED=true +set SERVICECONTROL_HTTPS_CERTIFICATEPATH=C:\path\to\ServiceControl\.local\certs\localhost.pfx +set SERVICECONTROL_HTTPS_CERTIFICATEPASSWORD=changeit +set SERVICECONTROL_FORWARDEDHEADERS_ENABLED=false - - +dotnet run +``` - - +**Test with curl:** - - - +```cmd +curl --ssl-no-revoke -v https://localhost:33333/api 2>&1 | findstr /C:"HTTP/" /C:"SSL" ``` -> **Note:** Replace `C:\path\to\repo` with the actual path to your ServiceControl repository. Use the full absolute path to the PFX file. +> **Note:** The `--ssl-no-revoke` flag is required on Windows because mkcert certificates don't have CRL distribution points, causing `CRYPT_E_NO_REVOCATION_CHECK` errors. -## Step 4: Start ServiceControl Instances +**Expected output:** -Start the ServiceControl instances locally using your preferred method: +```text +* schannel: SSL/TLS connection renegotiated +< HTTP/1.1 200 OK +``` -### **Option A: Visual Studio** +The request succeeds over HTTPS. The exact SSL output varies by curl version and platform, but you should see `HTTP/1.1 200 OK` confirming success. -1. Open `src/ServiceControl.sln` -2. Run the desired project(s) with the appropriate launch profile +### Scenario 2: HTTP Disabled (HTTPS Only) -### **Option B: Command Line** +Verify that HTTP requests fail when only HTTPS is enabled. -```bash -# Run ServiceControl (Primary) -dotnet run --project src/ServiceControl/ServiceControl.csproj +**Cleanup and start ServiceControl:** -# Run ServiceControl.Audit -dotnet run --project src/ServiceControl.Audit/ServiceControl.Audit.csproj +```cmd +set SERVICECONTROL_HTTPS_ENABLED=true +set SERVICECONTROL_HTTPS_CERTIFICATEPATH=C:\path\to\ServiceControl\.local\certs\localhost.pfx +set SERVICECONTROL_HTTPS_CERTIFICATEPASSWORD=changeit +set SERVICECONTROL_FORWARDEDHEADERS_ENABLED=false -# Run ServiceControl.Monitoring -dotnet run --project src/ServiceControl.Monitoring/ServiceControl.Monitoring.csproj +dotnet run ``` -## Step 5: Verify the Setup - -Test that HTTPS is working correctly: +**Test with curl (HTTP):** -```bash -# Test ServiceControl (Primary) -curl https://localhost:33333/api +```cmd +curl http://localhost:33333/api +``` -# Test ServiceControl.Audit -curl https://localhost:44444/api +**Expected output:** -# Test ServiceControl.Monitoring -curl https://localhost:33633/api +```text +curl: (52) Empty reply from server ``` -If you've installed mkcert's root CA, the requests should succeed without certificate warnings. +HTTP requests fail because Kestrel is listening for HTTPS but receives plaintext HTTP, which it cannot process. The server closes the connection without responding. -### Testing HTTPS Redirection +## HTTPS Configuration Reference -If `RedirectHttpToHttps` is enabled, HTTP requests should redirect to HTTPS: +| App.config Key | Environment Variable (Primary) | Default | Description | +|----------------|-------------------------------|---------|-------------| +| `Https.Enabled` | `SERVICECONTROL_HTTPS_ENABLED` | `false` | Enable Kestrel HTTPS | +| `Https.CertificatePath` | `SERVICECONTROL_HTTPS_CERTIFICATEPATH` | - | Path to PFX certificate file | +| `Https.CertificatePassword` | `SERVICECONTROL_HTTPS_CERTIFICATEPASSWORD` | - | Certificate password (empty string for no password) | +| `Https.RedirectHttpToHttps` | `SERVICECONTROL_HTTPS_REDIRECTHTTPTOHTTPS` | `false` | Redirect HTTP requests to HTTPS (reverse proxy only) | +| `Https.EnableHsts` | `SERVICECONTROL_HTTPS_ENABLEHSTS` | `false` | Enable HTTP Strict Transport Security | +| `Https.HstsMaxAgeSeconds` | `SERVICECONTROL_HTTPS_HSTSMAXAGESECONDS` | `31536000` | HSTS max-age (1 year) | +| `Https.HstsIncludeSubDomains` | `SERVICECONTROL_HTTPS_HSTSINCLUDESUBDOMAINS` | `false` | Include subdomains in HSTS | -```bash -# This should redirect to https://localhost:33333/api -curl -v http://localhost:33333/api -``` +> **Note:** For other instances, replace the `SERVICECONTROL_` prefix with the appropriate instance prefix (see Instance Reference table). +> +> **Note:** HSTS is not tested locally because ASP.NET Core excludes localhost from HSTS by default (to prevent accidentally caching HSTS during development). HSTS will work correctly in production with non-localhost hostnames. -### Testing HSTS +## Testing Other Instances -If `EnableHsts` is enabled, the response should include the `Strict-Transport-Security` header: +The same scenarios can be run against ServiceControl.Audit and ServiceControl.Monitoring by: -```bash -curl -v https://localhost:33333/api 2>&1 | grep -i strict-transport-security +1. Using the appropriate environment variable prefix +2. Running from the correct project directory +3. Using the correct port + +**ServiceControl.Audit:** + +```cmd +set SERVICECONTROL_AUDIT_HTTPS_ENABLED=true +set SERVICECONTROL_AUDIT_HTTPS_CERTIFICATEPATH=C:\path\to\ServiceControl\.local\certs\localhost.pfx +set SERVICECONTROL_AUDIT_HTTPS_CERTIFICATEPASSWORD=changeit + +dotnet run --project src/ServiceControl.Audit/ServiceControl.Audit.csproj ``` -## HTTPS Configuration Reference +```cmd +curl --ssl-no-revoke https://localhost:44444/api +``` -| Setting | Default | Description | -|---------|---------|-------------| -| `Https.Enabled` | `false` | Enable Kestrel HTTPS | -| `Https.CertificatePath` | - | Path to PFX certificate file | -| `Https.CertificatePassword` | - | Certificate password (empty string for no password) | -| `Https.RedirectHttpToHttps` | `false` | Redirect HTTP requests to HTTPS | -| `Https.EnableHsts` | `false` | Enable HTTP Strict Transport Security | -| `Https.HstsMaxAgeSeconds` | `31536000` | HSTS max-age (1 year) | -| `Https.HstsIncludeSubDomains` | `false` | Include subdomains in HSTS | +**ServiceControl.Monitoring:** + +```cmd +set MONITORING_HTTPS_ENABLED=true +set MONITORING_HTTPS_CERTIFICATEPATH=C:\path\to\ServiceControl\.local\certs\localhost.pfx +set MONITORING_HTTPS_CERTIFICATEPASSWORD=changeit + +dotnet run --project src/ServiceControl.Monitoring/ServiceControl.Monitoring.csproj +``` + +```cmd +curl --ssl-no-revoke https://localhost:33633/api +``` ## Troubleshooting @@ -181,16 +208,65 @@ Ensure the `CertificatePath` is an absolute path and the file exists. If you set a password when generating the PFX, ensure it matches `CertificatePassword` in the config. -### Certificate errors in browser +### Certificate errors in browser/curl 1. Ensure mkcert's root CA is installed: `mkcert -install` 2. Restart your browser after installing the root CA +### CRYPT_E_NO_REVOCATION_CHECK error in curl + +Windows curl fails to check certificate revocation for mkcert certificates because they don't have CRL distribution points. Use the `--ssl-no-revoke` flag: + +```cmd +curl --ssl-no-revoke https://localhost:33333/api +``` + ### Port already in use Ensure no other process is using the ServiceControl ports (33333, 44444, 33633). +## Cleanup + +After testing, clear the environment variables: + +**Command Prompt (cmd):** + +```cmd +set SERVICECONTROL_HTTPS_ENABLED= +set SERVICECONTROL_HTTPS_CERTIFICATEPATH= +set SERVICECONTROL_HTTPS_CERTIFICATEPASSWORD= +set SERVICECONTROL_HTTPS_ENABLEHSTS= +set SERVICECONTROL_HTTPS_HSTSMAXAGESECONDS= +set SERVICECONTROL_HTTPS_HSTSINCLUDESUBDOMAINS= +set SERVICECONTROL_FORWARDEDHEADERS_ENABLED= +``` + +**PowerShell:** + +```powershell +$env:SERVICECONTROL_HTTPS_ENABLED = $null +$env:SERVICECONTROL_HTTPS_CERTIFICATEPATH = $null +$env:SERVICECONTROL_HTTPS_CERTIFICATEPASSWORD = $null +$env:SERVICECONTROL_HTTPS_ENABLEHSTS = $null +$env:SERVICECONTROL_HTTPS_HSTSMAXAGESECONDS = $null +$env:SERVICECONTROL_HTTPS_HSTSINCLUDESUBDOMAINS = $null +$env:SERVICECONTROL_FORWARDEDHEADERS_ENABLED = $null +``` + +**Bash (Git Bash, WSL, Linux, macOS):** + +```bash +unset SERVICECONTROL_HTTPS_ENABLED +unset SERVICECONTROL_HTTPS_CERTIFICATEPATH +unset SERVICECONTROL_HTTPS_CERTIFICATEPASSWORD +unset SERVICECONTROL_HTTPS_ENABLEHSTS +unset SERVICECONTROL_HTTPS_HSTSMAXAGESECONDS +unset SERVICECONTROL_HTTPS_HSTSINCLUDESUBDOMAINS +unset SERVICECONTROL_FORWARDEDHEADERS_ENABLED +``` + ## See Also - [Hosting Guide](hosting-guide.md) - Detailed configuration reference for all deployment scenarios -- [Local NGINX Testing](local-nginx-testing.md) - Testing with a reverse proxy +- [Local Reverse Proxy Testing](local-reverseproxy-testing.md) - Testing with a reverse proxy (NGINX) +- [Local Forwarded Headers Testing](local-forward-headers-testing.md) - Testing forwarded headers without a reverse proxy diff --git a/docs/local-nginx-testing.md b/docs/local-reverseproxy-testing.md similarity index 63% rename from docs/local-nginx-testing.md rename to docs/local-reverseproxy-testing.md index 2703206ac8..5cfb933442 100644 --- a/docs/local-nginx-testing.md +++ b/docs/local-reverseproxy-testing.md @@ -79,10 +79,8 @@ mkcert -cert-file local-platform.pem -key-file local-platform-key.pem \ Create `.local/compose.yml`: ```yaml -name: service-platform - services: - reverse-proxy: + reverse-proxy-servicecontrol: image: nginx:alpine ports: - "80:80" @@ -93,6 +91,8 @@ services: - ./certs/local-platform-key.pem:/etc/nginx/certs/local-key.pem:ro ``` +Ensure no other NGINX containers are running. + ## Step 4: Create NGINX Configuration Create `.local/nginx.conf`: @@ -113,13 +113,36 @@ http { ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers HIGH:!aNULL:!MD5; - # ServiceControl (Primary) + # ServiceControl (Primary) - 443 server { listen 443 ssl; server_name servicecontrol.localhost; location / { - proxy_pass http://host.docker.internal:33333; + # REPLACE: with local machine IP address running service control + proxy_pass http://IPADDRESS:33333; + + # WebSocket Support + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + + # Forwarded Headers + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + } + + # ServiceControl (Primary) - 80 - Used to test HTTP-HTTPS redirection + server { + listen 80; + server_name servicecontrol.localhost; + + location / { + # REPLACE: with local machine IP address running service control + proxy_pass http://IPADDRESS:33333; # WebSocket Support proxy_http_version 1.1; @@ -239,9 +262,12 @@ Start the ServiceControl instances locally using your preferred method: ### **Option B: Command Line** +Navigate to the project folder. + ```bash # Run ServiceControl (Primary) -dotnet run --project src/ServiceControl/ServiceControl.csproj +dotnet build +dotnet run # Run ServiceControl.Audit dotnet run --project src/ServiceControl.Audit/ServiceControl.Audit.csproj @@ -254,18 +280,52 @@ dotnet run --project src/ServiceControl.Monitoring/ServiceControl.Monitoring.csp Test that the reverse proxy is working correctly: -```bash -# Test ServiceControl (Primary) -curl -k https://servicecontrol.localhost/api +When running in the Development environment, a `/debug/request-info` endpoint is available to diagnose forwarded headers configuration: + +```powershell +# Direct to ServiceControl (bypassing proxy) +Invoke-RestMethod http://localhost:33333/debug/request-info | ConvertTo-Json -Depth 5 -# Test ServiceControl.Audit -curl -k https://servicecontrol-audit.localhost/api +# Through the reverse proxy (skip certificate check for self-signed certs) +Invoke-RestMethod https://servicecontrol.localhost/debug/request-info -SkipCertificateCheck | ConvertTo-Json -Depth 5 +``` -# Test ServiceControl.Monitoring -curl -k https://servicecontrol-monitor.localhost/api +This endpoint returns detailed information including: + +- **processed**: Request values after forwarded headers processing +- **rawHeaders**: Raw `X-Forwarded-*` header values (empty if consumed by middleware) +- **configuration**: Current forwarded headers configuration + +Example response: + +```json +{ + "processed": { + "scheme": "https", + "host": "servicecontrol.localhost", + "remoteIpAddress": "172.17.0.1" + }, + "rawHeaders": { + "xForwardedFor": "", + "xForwardedProto": "", + "xForwardedHost": "" + }, + "configuration": { + "enabled": true, + "trustAllProxies": false, + "knownProxies": ["127.0.0.1"], + "knownNetworks": [] + } +} ``` -The `-k` flag is used to accept the self-signed certificate. If you've installed mkcert's root CA, you can omit it. +### Key Diagnostic Questions + +1. **Were headers applied?** - If `rawHeaders` are empty but `processed` values changed, the middleware consumed and applied them +2. **Why weren't headers applied?** - If `rawHeaders` still contain values, the middleware didn't trust the caller. Check `knownProxies` and `knownNetworks` in `configuration` +3. **Is forwarded headers enabled?** - Check `configuration.enabled` + +> **Note:** This endpoint is only available when `ASPNETCORE_ENVIRONMENT` is set to `Development`. ## Final Directory Structure @@ -310,6 +370,80 @@ When the proxy is **not** trusted (incorrect `KnownProxies`): - `Request.Host` remains the internal hostname - The request is still processed (not blocked) +## Testing HTTP to HTTPS Redirection + +The `RedirectHttpToHttps` setting enables ASP.NET Core's HTTPS redirection middleware. This is designed for reverse proxy scenarios where: + +1. The proxy forwards HTTP requests to ServiceControl +2. The proxy sends `X-Forwarded-Proto: http` to indicate the original protocol +3. ServiceControl responds with a 307 redirect to the HTTPS URL + +### Configure ServiceControl for Redirection + +Add the following to your `App.config`: + +```xml + + + + + +``` + +Or use environment variables: + +```cmd +set SERVICECONTROL_FORWARDEDHEADERS_ENABLED=true +set SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES=127.0.0.1 +set SERVICECONTROL_HTTPS_REDIRECTHTTPTOHTTPS=true + +dotnet run +``` + +### Test the Redirection + +The NGINX configuration includes an HTTP server on port 80 that forwards `X-Forwarded-Proto: http`. Test with curl: + +```bash +# Request via HTTP - should receive a 307 redirect to HTTPS +curl -v http://servicecontrol.localhost/api 2>&1 | grep -E "< HTTP|< Location" +``` + +**Expected output:** + +```text +< HTTP/1.1 307 Temporary Redirect +< Location: https://servicecontrol.localhost/api +``` + +The middleware detects `X-Forwarded-Proto: http` and redirects the client to the HTTPS URL. + +### Verify Without Redirection + +With `RedirectHttpToHttps` disabled (or not set), HTTP requests are processed normally: + +```bash +# Request via HTTP - should receive 200 OK (no redirect) +curl -v http://servicecontrol.localhost/api 2>&1 | grep "< HTTP" +``` + +**Expected output:** + +```text +< HTTP/1.1 200 OK +``` + +### How It Works + +1. Client sends HTTP request to `http://servicecontrol.localhost/api` +2. NGINX receives on port 80 and forwards to ServiceControl with `X-Forwarded-Proto: http` +3. ServiceControl's forwarded headers middleware processes the header (from trusted proxy) +4. `Request.Scheme` is set to `http` based on `X-Forwarded-Proto` +5. HTTPS redirection middleware sees `Request.Scheme == "http"` and issues a 307 redirect +6. Client follows redirect to `https://servicecontrol.localhost/api` + +> **Note:** This redirection only works with a reverse proxy because ServiceControl needs to receive the `X-Forwarded-Proto` header to know the original protocol. Without a proxy, ServiceControl only binds to a single port and cannot perform HTTP to HTTPS redirection. See [Local HTTPS Testing](local-https-testing.md) for direct HTTPS scenarios. + ## Troubleshooting ### "Connection refused" errors diff --git a/src/ServiceControl.Hosting/ForwardedHeaders/WebApplicationExtensions.cs b/src/ServiceControl.Hosting/ForwardedHeaders/WebApplicationExtensions.cs index 284d3f9add..adcce44281 100644 --- a/src/ServiceControl.Hosting/ForwardedHeaders/WebApplicationExtensions.cs +++ b/src/ServiceControl.Hosting/ForwardedHeaders/WebApplicationExtensions.cs @@ -1,13 +1,54 @@ namespace ServiceControl.Hosting.ForwardedHeaders; +using System.Linq; using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.Extensions.Hosting; using ServiceControl.Infrastructure; public static class WebApplicationExtensions { public static void UseServiceControlForwardedHeaders(this WebApplication app, ForwardedHeadersSettings settings) { + // Register debug endpoint first (before early return) so it's always available in Development + if (app.Environment.IsDevelopment()) + { + app.MapGet("/debug/request-info", (HttpContext context) => + { + var remoteIp = context.Connection.RemoteIpAddress; + + // Processed values (after ForwardedHeaders middleware, if enabled) + var scheme = context.Request.Scheme; + var host = context.Request.Host.ToString(); + var remoteIpAddress = remoteIp?.ToString(); + + // Raw forwarded headers (what remains after middleware processing) + // Note: When ForwardedHeaders middleware processes headers from a trusted proxy, + // it consumes (removes) them from the request headers + var xForwardedFor = context.Request.Headers["X-Forwarded-For"].ToString(); + var xForwardedProto = context.Request.Headers["X-Forwarded-Proto"].ToString(); + var xForwardedHost = context.Request.Headers["X-Forwarded-Host"].ToString(); + + // Configuration + var knownProxies = settings.KnownProxies.Select(p => p.ToString()).ToArray(); + var knownNetworks = settings.KnownNetworks.ToArray(); + + return new + { + processed = new { scheme, host, remoteIpAddress }, + rawHeaders = new { xForwardedFor, xForwardedProto, xForwardedHost }, + configuration = new + { + enabled = settings.Enabled, + trustAllProxies = settings.TrustAllProxies, + knownProxies, + knownNetworks + } + }; + }); + } + if (!settings.Enabled) { return; diff --git a/src/ServiceControl.Infrastructure/ForwardedHeadersSettings.cs b/src/ServiceControl.Infrastructure/ForwardedHeadersSettings.cs index a27c0c7e76..852868f228 100644 --- a/src/ServiceControl.Infrastructure/ForwardedHeadersSettings.cs +++ b/src/ServiceControl.Infrastructure/ForwardedHeadersSettings.cs @@ -15,11 +15,6 @@ public ForwardedHeadersSettings(SettingsRootNamespace rootNamespace) { Enabled = SettingsReader.Read(rootNamespace, "ForwardedHeaders.Enabled", true); - if (!Enabled) - { - return; - } - // Default to trusting all proxies for backwards compatibility // Customers can set this to false and configure KnownProxies/KnownNetworks for better security TrustAllProxies = SettingsReader.Read(rootNamespace, "ForwardedHeaders.TrustAllProxies", true); @@ -114,12 +109,6 @@ List ParseNetworks(string value) void LogConfiguration() { - if (!Enabled) - { - logger.LogInformation("Forwarded headers processing is disabled"); - return; - } - logger.LogInformation("Forwarded headers configuration:"); logger.LogInformation(" Enabled: {Enabled}", Enabled); logger.LogInformation(" TrustAllProxies: {TrustAllProxies}", TrustAllProxies); diff --git a/src/ServiceControl.Infrastructure/HttpsSettings.cs b/src/ServiceControl.Infrastructure/HttpsSettings.cs index d0954dd474..de7a116ab0 100644 --- a/src/ServiceControl.Infrastructure/HttpsSettings.cs +++ b/src/ServiceControl.Infrastructure/HttpsSettings.cs @@ -77,30 +77,14 @@ void ValidateCertificateConfiguration() void LogConfiguration() { - if (!Enabled && !RedirectHttpToHttps && !EnableHsts) - { - return; - } - logger.LogInformation("HTTPS configuration:"); - if (Enabled) - { - logger.LogInformation(" Enabled: {Enabled}", Enabled); - logger.LogInformation(" CertificatePath: {CertificatePath}", CertificatePath); - logger.LogInformation(" CertificatePassword: {CertificatePassword}", string.IsNullOrEmpty(CertificatePassword) ? "(not set)" : "(set)"); - } - - if (RedirectHttpToHttps) - { - logger.LogInformation(" RedirectHttpToHttps: {RedirectHttpToHttps}", RedirectHttpToHttps); - } - - if (EnableHsts) - { - logger.LogInformation(" EnableHsts: {EnableHsts}", EnableHsts); - logger.LogInformation(" HstsMaxAgeSeconds: {HstsMaxAgeSeconds}", HstsMaxAgeSeconds); - logger.LogInformation(" HstsIncludeSubDomains: {HstsIncludeSubDomains}", HstsIncludeSubDomains); - } + logger.LogInformation(" Enabled: {Enabled}", Enabled); + logger.LogInformation(" CertificatePath: {CertificatePath}", CertificatePath); + logger.LogInformation(" CertificatePassword: {CertificatePassword}", string.IsNullOrEmpty(CertificatePassword) ? "(not set)" : "(set)"); + logger.LogInformation(" RedirectHttpToHttps: {RedirectHttpToHttps}", RedirectHttpToHttps); + logger.LogInformation(" EnableHsts: {EnableHsts}", EnableHsts); + logger.LogInformation(" HstsMaxAgeSeconds: {HstsMaxAgeSeconds}", HstsMaxAgeSeconds); + logger.LogInformation(" HstsIncludeSubDomains: {HstsIncludeSubDomains}", HstsIncludeSubDomains); } } diff --git a/src/ServiceControl/App.config b/src/ServiceControl/App.config index 16a44d67c3..079f6f6adc 100644 --- a/src/ServiceControl/App.config +++ b/src/ServiceControl/App.config @@ -51,9 +51,9 @@ These settings are only here so that we can debug ServiceControl while developin - - - + + + diff --git a/src/ServiceControl/Properties/launchSettings.json b/src/ServiceControl/Properties/launchSettings.json index 82bf837891..e9f96331bf 100644 --- a/src/ServiceControl/Properties/launchSettings.json +++ b/src/ServiceControl/Properties/launchSettings.json @@ -3,7 +3,6 @@ "ServiceControl": { "commandName": "Project", "launchBrowser": false, - "applicationUrl": "http://0.0.0.0:33333", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } From 301323289e966b12f01449687936d363e0d91df6 Mon Sep 17 00:00:00 2001 From: Warwick Schroeder Date: Fri, 12 Dec 2025 15:41:45 +0800 Subject: [PATCH 18/24] Update reverse proxy test file --- docs/local-reverseproxy-testing.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/docs/local-reverseproxy-testing.md b/docs/local-reverseproxy-testing.md index 5cfb933442..f89880ae69 100644 --- a/docs/local-reverseproxy-testing.md +++ b/docs/local-reverseproxy-testing.md @@ -119,8 +119,7 @@ http { server_name servicecontrol.localhost; location / { - # REPLACE: with local machine IP address running service control - proxy_pass http://IPADDRESS:33333; + proxy_pass http://host.docker.internal:44444; # WebSocket Support proxy_http_version 1.1; @@ -141,8 +140,7 @@ http { server_name servicecontrol.localhost; location / { - # REPLACE: with local machine IP address running service control - proxy_pass http://IPADDRESS:33333; + proxy_pass http://host.docker.internal:44444; # WebSocket Support proxy_http_version 1.1; From 5fb9dbaf55373123df63b61ab441d96689a48f8c Mon Sep 17 00:00:00 2001 From: Warwick Schroeder Date: Mon, 15 Dec 2025 14:12:50 +0800 Subject: [PATCH 19/24] Update HTTPS config and documentation --- docs/local-forward-headers-testing.md | 69 ++- docs/local-https-testing.md | 57 +-- docs/local-reverseproxy-testing.md | 464 ++++++++++-------- src/ServiceControl.Audit/App.config | 3 +- .../WebApplicationExtensions.cs | 15 +- .../Https/HostApplicationBuilderExtensions.cs | 8 + .../HttpsSettings.cs | 4 + src/ServiceControl.Monitoring/App.config | 3 +- src/ServiceControl/App.config | 3 +- 9 files changed, 358 insertions(+), 268 deletions(-) diff --git a/docs/local-forward-headers-testing.md b/docs/local-forward-headers-testing.md index df18efc5a7..77aff04462 100644 --- a/docs/local-forward-headers-testing.md +++ b/docs/local-forward-headers-testing.md @@ -207,6 +207,7 @@ Only accept forwarded headers from specific IP addresses. ```cmd set SERVICECONTROL_FORWARDEDHEADERS_ENABLED=true +set SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES= set SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES=127.0.0.1,::1 set SERVICECONTROL_FORWARDEDHEADERS_KNOWNNETWORKS= @@ -254,8 +255,9 @@ Trust all proxies within a network range. ```cmd set SERVICECONTROL_FORWARDEDHEADERS_ENABLED=true -set SERVICECONTROL_FORWARDEDHEADERS_KNOWNNETWORKS=127.0.0.0/8,::1/128 +set SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES= set SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES= +set SERVICECONTROL_FORWARDEDHEADERS_KNOWNNETWORKS=127.0.0.0/8,::1/128 dotnet run ``` @@ -459,7 +461,7 @@ curl -H "X-Forwarded-Proto: https" -H "X-Forwarded-Host: example.com" -H "X-Forw "processed": { "scheme": "https", "host": "example.com", - "remoteIpAddress": "192.168.1.1" + "remoteIpAddress": "203.0.113.50" }, "rawHeaders": { "xForwardedFor": "", @@ -475,9 +477,55 @@ curl -H "X-Forwarded-Proto: https" -H "X-Forwarded-Host: example.com" -H "X-Forw } ``` -The `X-Forwarded-For` header contains multiple IPs representing the proxy chain. By default, ASP.NET Core's `ForwardLimit` is `1`, so only the last proxy IP is used. +The `X-Forwarded-For` header contains multiple IPs representing the proxy chain. When `TrustAllProxies` is `true`, `ForwardLimit` is set to `null` (no limit), so the middleware processes all IPs and returns the original client IP (`203.0.113.50`). + +### Scenario 9: Proxy Chain with Known Proxies (ForwardLimit = 1) + +Test how ServiceControl handles multiple proxies when `TrustAllProxies` is `false`. In this case, `ForwardLimit` remains at its default of `1`, so only the last proxy IP is processed. + +**Cleanup and start ServiceControl:** + +```cmd +set SERVICECONTROL_FORWARDEDHEADERS_ENABLED=true +set SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES= +set SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES=127.0.0.1,::1 +set SERVICECONTROL_FORWARDEDHEADERS_KNOWNNETWORKS= + +dotnet run +``` + +**Test with curl (simulating a proxy chain):** + +```cmd +curl -H "X-Forwarded-Proto: https" -H "X-Forwarded-Host: example.com" -H "X-Forwarded-For: 203.0.113.50, 10.0.0.1, 192.168.1.1" http://localhost:33333/debug/request-info | json +``` -### Scenario 9: Combined Known Proxies and Networks +**Expected output:** + +```json +{ + "processed": { + "scheme": "https", + "host": "example.com", + "remoteIpAddress": "192.168.1.1" + }, + "rawHeaders": { + "xForwardedFor": "203.0.113.50, 10.0.0.1", + "xForwardedProto": "", + "xForwardedHost": "" + }, + "configuration": { + "enabled": true, + "trustAllProxies": false, + "knownProxies": ["127.0.0.1", "::1"], + "knownNetworks": [] + } +} +``` + +When `TrustAllProxies` is `false`, `ForwardLimit` remains at its default of `1`. The middleware only processes the rightmost IP from the chain (`192.168.1.1`). The remaining IPs (`203.0.113.50, 10.0.0.1`) stay in the `X-Forwarded-For` header. Compare this to Scenario 8 where `TrustAllProxies = true` returns the original client IP. + +### Scenario 10: Combined Known Proxies and Networks Test using both `KnownProxies` and `KnownNetworks` together. @@ -523,7 +571,7 @@ curl -H "X-Forwarded-Proto: https" -H "X-Forwarded-Host: example.com" -H "X-Forw Headers are applied because the request comes from localhost (`::1`), which falls within the `::1/128` network even though it's not in the `knownProxies` list. -### Scenario 10: Partial Headers (Proto Only) +### Scenario 11: Partial Headers (Proto Only) Test that each forwarded header is processed independently. Only sending `X-Forwarded-Proto` should update the scheme while leaving host and remoteIpAddress unchanged. @@ -569,7 +617,7 @@ curl -H "X-Forwarded-Proto: https" http://localhost:33333/debug/request-info | j Only the `scheme` changed to `https`. The `host` remains `localhost:33333` and `remoteIpAddress` remains `::1` because those headers weren't sent. Each header is processed independently. -### Scenario 11: IPv4/IPv6 Mismatch +### Scenario 12: IPv4/IPv6 Mismatch Demonstrates a common misconfiguration where only IPv4 localhost is configured but curl uses IPv6. This scenario shows why you should include both `127.0.0.1` and `::1` in your configuration. @@ -685,15 +733,6 @@ $env:SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES = $null $env:SERVICECONTROL_FORWARDEDHEADERS_KNOWNNETWORKS = $null ``` -**Bash (Git Bash, WSL, Linux, macOS):** - -```bash -unset SERVICECONTROL_FORWARDEDHEADERS_ENABLED -unset SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES -unset SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES -unset SERVICECONTROL_FORWARDEDHEADERS_KNOWNNETWORKS -``` - ## Quick Reference: Testing Other Instances ### ServiceControl.Audit diff --git a/docs/local-https-testing.md b/docs/local-https-testing.md index fdd30e8318..de98c968ca 100644 --- a/docs/local-https-testing.md +++ b/docs/local-https-testing.md @@ -95,6 +95,9 @@ Verify that HTTPS is working with a valid certificate. set SERVICECONTROL_HTTPS_ENABLED=true set SERVICECONTROL_HTTPS_CERTIFICATEPATH=C:\path\to\ServiceControl\.local\certs\localhost.pfx set SERVICECONTROL_HTTPS_CERTIFICATEPASSWORD=changeit +set SERVICECONTROL_HTTPS_REDIRECTHTTPTOHTTPS= +set SERVICECONTROL_HTTPS_PORT= +set SERVICECONTROL_HTTPS_ENABLEHSTS= set SERVICECONTROL_FORWARDEDHEADERS_ENABLED=false dotnet run @@ -127,6 +130,9 @@ Verify that HTTP requests fail when only HTTPS is enabled. set SERVICECONTROL_HTTPS_ENABLED=true set SERVICECONTROL_HTTPS_CERTIFICATEPATH=C:\path\to\ServiceControl\.local\certs\localhost.pfx set SERVICECONTROL_HTTPS_CERTIFICATEPASSWORD=changeit +set SERVICECONTROL_HTTPS_REDIRECTHTTPTOHTTPS= +set SERVICECONTROL_HTTPS_PORT= +set SERVICECONTROL_HTTPS_ENABLEHSTS= set SERVICECONTROL_FORWARDEDHEADERS_ENABLED=false dotnet run @@ -164,39 +170,16 @@ HTTP requests fail because Kestrel is listening for HTTPS but receives plaintext ## Testing Other Instances -The same scenarios can be run against ServiceControl.Audit and ServiceControl.Monitoring by: - -1. Using the appropriate environment variable prefix -2. Running from the correct project directory -3. Using the correct port +The scenarios above use ServiceControl (Primary). To test ServiceControl.Audit or ServiceControl.Monitoring: -**ServiceControl.Audit:** +1. Use the appropriate environment variable prefix (see Instance Reference above) +2. Use the corresponding project directory and port -```cmd -set SERVICECONTROL_AUDIT_HTTPS_ENABLED=true -set SERVICECONTROL_AUDIT_HTTPS_CERTIFICATEPATH=C:\path\to\ServiceControl\.local\certs\localhost.pfx -set SERVICECONTROL_AUDIT_HTTPS_CERTIFICATEPASSWORD=changeit - -dotnet run --project src/ServiceControl.Audit/ServiceControl.Audit.csproj -``` - -```cmd -curl --ssl-no-revoke https://localhost:44444/api -``` - -**ServiceControl.Monitoring:** - -```cmd -set MONITORING_HTTPS_ENABLED=true -set MONITORING_HTTPS_CERTIFICATEPATH=C:\path\to\ServiceControl\.local\certs\localhost.pfx -set MONITORING_HTTPS_CERTIFICATEPASSWORD=changeit - -dotnet run --project src/ServiceControl.Monitoring/ServiceControl.Monitoring.csproj -``` - -```cmd -curl --ssl-no-revoke https://localhost:33633/api -``` +| Instance | Project Directory | Port | Env Var Prefix | +|----------|-------------------|------|----------------| +| ServiceControl (Primary) | `src\ServiceControl` | 33333 | `SERVICECONTROL_` | +| ServiceControl.Audit | `src\ServiceControl.Audit` | 44444 | `SERVICECONTROL_AUDIT_` | +| ServiceControl.Monitoring | `src\ServiceControl.Monitoring` | 33633 | `MONITORING_` | ## Troubleshooting @@ -253,18 +236,6 @@ $env:SERVICECONTROL_HTTPS_HSTSINCLUDESUBDOMAINS = $null $env:SERVICECONTROL_FORWARDEDHEADERS_ENABLED = $null ``` -**Bash (Git Bash, WSL, Linux, macOS):** - -```bash -unset SERVICECONTROL_HTTPS_ENABLED -unset SERVICECONTROL_HTTPS_CERTIFICATEPATH -unset SERVICECONTROL_HTTPS_CERTIFICATEPASSWORD -unset SERVICECONTROL_HTTPS_ENABLEHSTS -unset SERVICECONTROL_HTTPS_HSTSMAXAGESECONDS -unset SERVICECONTROL_HTTPS_HSTSINCLUDESUBDOMAINS -unset SERVICECONTROL_FORWARDEDHEADERS_ENABLED -``` - ## See Also - [Hosting Guide](hosting-guide.md) - Detailed configuration reference for all deployment scenarios diff --git a/docs/local-reverseproxy-testing.md b/docs/local-reverseproxy-testing.md index f89880ae69..ddf92eddd9 100644 --- a/docs/local-reverseproxy-testing.md +++ b/docs/local-reverseproxy-testing.md @@ -1,80 +1,66 @@ # Local Testing with NGINX Reverse Proxy -This guide explains how to set up a local development environment with NGINX as a reverse proxy in front of ServiceControl instances. This is useful for testing scenarios like: +This guide provides scenario-based tests for ServiceControl instances behind an NGINX reverse proxy. Use this to verify: - SSL/TLS termination at the reverse proxy - Forwarded headers handling (`X-Forwarded-For`, `X-Forwarded-Proto`, `X-Forwarded-Host`) -- Testing CORS configuration -- Simulating production deployment topology +- HTTP to HTTPS redirection +- HSTS (HTTP Strict Transport Security) +- WebSocket support (SignalR) + +## Instance Reference + +| Instance | Project Directory | Default Port | Hostname | Environment Variable Prefix | +|----------|-------------------|--------------|----------|----------------------------| +| ServiceControl (Primary) | `src\ServiceControl` | 33333 | `servicecontrol.localhost` | `SERVICECONTROL_` | +| ServiceControl.Audit | `src\ServiceControl.Audit` | 44444 | `servicecontrol-audit.localhost` | `SERVICECONTROL_AUDIT_` | +| ServiceControl.Monitoring | `src\ServiceControl.Monitoring` | 33633 | `servicecontrol-monitor.localhost` | `MONITORING_` | ## Prerequisites - [Docker Desktop](https://www.docker.com/products/docker-desktop/) installed and running - [mkcert](https://github.com/FiloSottile/mkcert) for generating local development certificates - ServiceControl built locally (see main README for build instructions) +- curl (included with Windows 10/11, Git Bash, or WSL) ### Installing mkcert **Windows (using Chocolatey):** -```powershell +```cmd choco install mkcert ``` **Windows (using Scoop):** -```powershell +```cmd scoop install mkcert ``` -**macOS (using Homebrew):** - -```bash -brew install mkcert -``` - -**Linux:** - -```bash -# Debian/Ubuntu -sudo apt install libnss3-tools -# Then download from https://github.com/FiloSottile/mkcert/releases - -# Arch Linux -sudo pacman -S mkcert -``` - After installing, run `mkcert -install` to install the local CA in your system trust store. -## Step 1: Create the Local Development Folder +## Setup + +### Step 1: Create the Local Development Folder Create a `.local` folder in the repository root (this folder is gitignored): -```bash +```cmd mkdir .local -mkdir .local/certs +mkdir .local\certs ``` -## Step 2: Generate SSL Certificates +### Step 2: Generate SSL Certificates Use mkcert to generate trusted local development certificates: -```bash -# Install mkcert's root CA (one-time setup) +```cmd mkcert -install - -# Navigate to the certs folder -cd .local/certs - -# Generate certificates for all ServiceControl hostnames -mkcert -cert-file local-platform.pem -key-file local-platform-key.pem \ - servicecontrol.localhost \ - servicecontrol-audit.localhost \ - servicecontrol-monitor.localhost \ - localhost +cd .local\certs +mkcert -cert-file local-platform.pem -key-file local-platform-key.pem servicecontrol.localhost servicecontrol-audit.localhost servicecontrol-monitor.localhost localhost ``` -## Step 3: Create Docker Compose Configuration +### Step 3: Create Docker Compose Configuration Create `.local/compose.yml`: @@ -91,9 +77,7 @@ services: - ./certs/local-platform-key.pem:/etc/nginx/certs/local-key.pem:ro ``` -Ensure no other NGINX containers are running. - -## Step 4: Create NGINX Configuration +### Step 4: Create NGINX Configuration Create `.local/nginx.conf`: @@ -113,13 +97,13 @@ http { ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers HIGH:!aNULL:!MD5; - # ServiceControl (Primary) - 443 + # ServiceControl (Primary) - HTTPS server { listen 443 ssl; server_name servicecontrol.localhost; location / { - proxy_pass http://host.docker.internal:44444; + proxy_pass http://host.docker.internal:33333; # WebSocket Support proxy_http_version 1.1; @@ -131,16 +115,17 @@ http { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; } } - # ServiceControl (Primary) - 80 - Used to test HTTP-HTTPS redirection + # ServiceControl (Primary) - HTTP (for testing HTTP-to-HTTPS redirect) server { listen 80; server_name servicecontrol.localhost; location / { - proxy_pass http://host.docker.internal:44444; + proxy_pass http://host.docker.internal:33333; # WebSocket Support proxy_http_version 1.1; @@ -152,10 +137,11 @@ http { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; } } - # ServiceControl.Audit + # ServiceControl.Audit - HTTPS server { listen 443 ssl; server_name servicecontrol-audit.localhost; @@ -173,10 +159,33 @@ http { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; } } - # ServiceControl.Monitoring + # ServiceControl.Audit - HTTP (for testing HTTP-to-HTTPS redirect) + server { + listen 80; + server_name servicecontrol-audit.localhost; + + location / { + proxy_pass http://host.docker.internal:44444; + + # WebSocket Support + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + + # Forwarded Headers + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + } + } + + # ServiceControl.Monitoring - HTTPS server { listen 443 ssl; server_name servicecontrol-monitor.localhost; @@ -194,17 +203,37 @@ http { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + } + } + + # ServiceControl.Monitoring - HTTP (for testing HTTP-to-HTTPS redirect) + server { + listen 80; + server_name servicecontrol-monitor.localhost; + + location / { + proxy_pass http://host.docker.internal:33633; + + # WebSocket Support + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + + # Forwarded Headers + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; } } } ``` -## Step 5: Configure Hosts File - -Add the following entries to your hosts file: +### Step 5: Configure Hosts File -**Windows:** `C:\Windows\System32\drivers\etc\hosts` -**Linux/macOS:** `/etc/hosts` +Add the following entries to your hosts file (`C:\Windows\System32\drivers\etc\hosts`): ```text 127.0.0.1 servicecontrol.localhost @@ -212,96 +241,94 @@ Add the following entries to your hosts file: 127.0.0.1 servicecontrol-monitor.localhost ``` -## Step 6: Configure ServiceControl Instances +### Step 6: Start the NGINX Reverse Proxy -Configure forwarded headers in the `App.config` file for each ServiceControl instance. See [Forwarded Headers Settings](hosting-guide.md#forwarded-headers-settings) in the Hosting Guide for all available options. - -For local testing with this NGINX setup, set `KnownProxies` to `127.0.0.1`: - -| Instance | Config Key Prefix | App.config Location | -|----------|-------------------|---------------------| -| ServiceControl (Primary) | `ServiceControl/` | `src/ServiceControl/App.config` | -| ServiceControl.Audit | `ServiceControl.Audit/` | `src/ServiceControl.Audit/App.config` | -| ServiceControl.Monitoring | `Monitoring/` | `src/ServiceControl.Monitoring/App.config` | - -Example for ServiceControl (Primary): +From the repository root: -```xml - - - - +```cmd +docker compose -f .local/compose.yml up -d ``` -> **Note:** The `KnownProxies` value is `127.0.0.1` because NGINX running in Docker connects to the host via `host.docker.internal`, which resolves to `127.0.0.1` on the host machine. - -## Step 7: Start the NGINX Reverse Proxy +### Step 7: Final Directory Structure -From the repository root: +After completing the setup, your `.local` folder should look like: -```bash -docker compose -f .local/compose.yml up -d +```text +.local/ +├── compose.yml +├── nginx.conf +└── certs/ + ├── local-platform.pem + └── local-platform-key.pem ``` -This starts an NGINX container that: - -- Listens on ports 80 (HTTP) and 443 (HTTPS) -- Terminates SSL/TLS using the mkcert certificates -- Proxies requests to ServiceControl instances running on the host +## Test Scenarios -## Step 8: Start ServiceControl Instances +> **Important:** ServiceControl must be running before testing. A 502 Bad Gateway error means NGINX cannot reach ServiceControl. +> **Note:** Use `TRUSTALLPROXIES=true` for local Docker testing. The NGINX container's IP address varies based on Docker's network configuration (e.g., `172.x.x.x`), making it impractical to specify a fixed `KNOWNPROXIES` value. -Start the ServiceControl instances locally using your preferred method: +### Scenario 1: HTTPS Access -### **Option A: Visual Studio** +Verify that HTTPS is working through the reverse proxy. -1. Open `src/ServiceControl.sln` -2. Run the desired project(s) with the appropriate launch profile +**Clear environment variables and start ServiceControl:** -### **Option B: Command Line** +```cmd +set SERVICECONTROL_FORWARDEDHEADERS_ENABLED= +set SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES= +set SERVICECONTROL_HTTPS_REDIRECTHTTPTOHTTPS= +set SERVICECONTROL_HTTPS_PORT= +set SERVICECONTROL_HTTPS_ENABLEHSTS= + +cd src\ServiceControl +dotnet run --no-launch-profile +``` -Navigate to the project folder. +**Test with curl:** -```bash -# Run ServiceControl (Primary) -dotnet build -dotnet run +```cmd +curl -k -v https://servicecontrol.localhost/api 2>&1 | findstr /C:"HTTP/" +``` -# Run ServiceControl.Audit -dotnet run --project src/ServiceControl.Audit/ServiceControl.Audit.csproj +**Expected output:** -# Run ServiceControl.Monitoring -dotnet run --project src/ServiceControl.Monitoring/ServiceControl.Monitoring.csproj +```text +< HTTP/1.1 200 OK ``` -## Step 9: Verify the Setup +The request succeeds over HTTPS through the NGINX reverse proxy. -Test that the reverse proxy is working correctly: +### Scenario 2: Forwarded Headers Processing -When running in the Development environment, a `/debug/request-info` endpoint is available to diagnose forwarded headers configuration: +Verify that forwarded headers are being processed correctly. -```powershell -# Direct to ServiceControl (bypassing proxy) -Invoke-RestMethod http://localhost:33333/debug/request-info | ConvertTo-Json -Depth 5 +**Clear environment variables and start ServiceControl:** -# Through the reverse proxy (skip certificate check for self-signed certs) -Invoke-RestMethod https://servicecontrol.localhost/debug/request-info -SkipCertificateCheck | ConvertTo-Json -Depth 5 +```cmd +set SERVICECONTROL_FORWARDEDHEADERS_ENABLED=true +set SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES=true +set SERVICECONTROL_HTTPS_REDIRECTHTTPTOHTTPS= +set SERVICECONTROL_HTTPS_PORT= +set SERVICECONTROL_HTTPS_ENABLEHSTS= + +cd src\ServiceControl +dotnet run --no-launch-profile ``` -This endpoint returns detailed information including: +**Test with curl:** -- **processed**: Request values after forwarded headers processing -- **rawHeaders**: Raw `X-Forwarded-*` header values (empty if consumed by middleware) -- **configuration**: Current forwarded headers configuration +```cmd +curl -k https://servicecontrol.localhost/debug/request-info | json +``` -Example response: +**Expected output:** ```json { "processed": { "scheme": "https", "host": "servicecontrol.localhost", - "remoteIpAddress": "172.17.0.1" + "remoteIpAddress": "172.x.x.x" }, "rawHeaders": { "xForwardedFor": "", @@ -310,149 +337,181 @@ Example response: }, "configuration": { "enabled": true, - "trustAllProxies": false, - "knownProxies": ["127.0.0.1"], + "trustAllProxies": true, + "knownProxies": [], "knownNetworks": [] } } ``` -### Key Diagnostic Questions +The key indicators that forwarded headers are working: -1. **Were headers applied?** - If `rawHeaders` are empty but `processed` values changed, the middleware consumed and applied them -2. **Why weren't headers applied?** - If `rawHeaders` still contain values, the middleware didn't trust the caller. Check `knownProxies` and `knownNetworks` in `configuration` -3. **Is forwarded headers enabled?** - Check `configuration.enabled` +- `processed.scheme` is `https` (from `X-Forwarded-Proto`) +- `processed.host` is `servicecontrol.localhost` (from `X-Forwarded-Host`) +- `rawHeaders` are empty because the middleware consumed them (trusted proxy) -> **Note:** This endpoint is only available when `ASPNETCORE_ENVIRONMENT` is set to `Development`. +### Scenario 3: HTTP to HTTPS Redirect -## Final Directory Structure +Verify that HTTP requests are redirected to HTTPS. -After completing the setup, your `.local` folder should look like: +**Clear environment variables and start ServiceControl:** -```text -.local/ -├── compose.yml -├── nginx.conf -└── certs/ - ├── local-platform.pem - └── local-platform-key.pem +```cmd +set SERVICECONTROL_FORWARDEDHEADERS_ENABLED=true +set SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES=true +set SERVICECONTROL_HTTPS_REDIRECTHTTPTOHTTPS=true +set SERVICECONTROL_HTTPS_PORT=443 +set SERVICECONTROL_HTTPS_ENABLEHSTS= + +cd src\ServiceControl +dotnet run --no-launch-profile ``` -## NGINX Configuration Reference +**Test with curl:** -| Server Name | HTTPS Port | Backend Port | Instance | -|------------|------------|--------------|----------| -| `servicecontrol.localhost` | 443 | 33333 | ServiceControl (Primary) | -| `servicecontrol-audit.localhost` | 443 | 44444 | ServiceControl.Audit | -| `servicecontrol-monitor.localhost` | 443 | 33633 | ServiceControl.Monitoring | +```cmd +curl -v http://servicecontrol.localhost/api 2>&1 | findstr /i location +``` -Each server block: +**Expected output:** -- Terminates SSL/TLS -- Sets forwarded headers (`X-Forwarded-For`, `X-Forwarded-Proto`, `X-Forwarded-Host`) -- Supports WebSocket connections (for SignalR) -- Proxies to `host.docker.internal` to reach the host machine +```text +< Location: https://servicecontrol.localhost/api +``` -## Forwarded Headers Behavior +HTTP requests are redirected to HTTPS with a 307 (Temporary Redirect) status. -When `ForwardedHeaders.KnownProxies` is configured correctly: +### Scenario 4: HSTS -- `Request.Scheme` will be `https` (from `X-Forwarded-Proto`) -- `Request.Host` will be the external hostname (from `X-Forwarded-Host`) -- Client IP will be available from `X-Forwarded-For` +Verify that the HSTS header is included in HTTPS responses. -When the proxy is **not** trusted (incorrect `KnownProxies`): +> **Note:** HSTS is disabled in Development environment. You must use `--no-launch-profile` to prevent launchSettings.json from overriding it. -- `X-Forwarded-*` headers are **ignored** (not applied to the request) -- `Request.Scheme` remains `http` -- `Request.Host` remains the internal hostname -- The request is still processed (not blocked) +**Clear environment variables and start ServiceControl:** -## Testing HTTP to HTTPS Redirection +```cmd +set SERVICECONTROL_FORWARDEDHEADERS_ENABLED=true +set SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES=true +set SERVICECONTROL_HTTPS_REDIRECTHTTPTOHTTPS= +set SERVICECONTROL_HTTPS_PORT= +set SERVICECONTROL_HTTPS_ENABLEHSTS=true -The `RedirectHttpToHttps` setting enables ASP.NET Core's HTTPS redirection middleware. This is designed for reverse proxy scenarios where: +cd src\ServiceControl +dotnet run --environment Production --no-launch-profile +``` -1. The proxy forwards HTTP requests to ServiceControl -2. The proxy sends `X-Forwarded-Proto: http` to indicate the original protocol -3. ServiceControl responds with a 307 redirect to the HTTPS URL +**Test with curl:** -### Configure ServiceControl for Redirection +```cmd +curl -k -v https://servicecontrol.localhost/api 2>&1 | findstr /i strict-transport-security +``` -Add the following to your `App.config`: +**Expected output:** -```xml - - - - - +```text +< Strict-Transport-Security: max-age=31536000 ``` -Or use environment variables: +The HSTS header is present with the default max-age of 1 year. -```cmd -set SERVICECONTROL_FORWARDEDHEADERS_ENABLED=true -set SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES=127.0.0.1 -set SERVICECONTROL_HTTPS_REDIRECTHTTPTOHTTPS=true +## Testing Other Instances -dotnet run -``` +The scenarios above use ServiceControl (Primary). To test ServiceControl.Audit or ServiceControl.Monitoring: -### Test the Redirection +1. Use the appropriate environment variable prefix (see Configuration Reference below) +2. Use the corresponding project directory and hostname -The NGINX configuration includes an HTTP server on port 80 that forwards `X-Forwarded-Proto: http`. Test with curl: +| Instance | Project Directory | Hostname | Env Var Prefix | +|----------|-------------------|----------|----------------| +| ServiceControl (Primary) | `src\ServiceControl` | `servicecontrol.localhost` | `SERVICECONTROL_` | +| ServiceControl.Audit | `src\ServiceControl.Audit` | `servicecontrol-audit.localhost` | `SERVICECONTROL_AUDIT_` | +| ServiceControl.Monitoring | `src\ServiceControl.Monitoring` | `servicecontrol-monitor.localhost` | `MONITORING_` | -```bash -# Request via HTTP - should receive a 307 redirect to HTTPS -curl -v http://servicecontrol.localhost/api 2>&1 | grep -E "< HTTP|< Location" -``` +## Configuration Reference -**Expected output:** +| Environment Variable | Default | Description | +|---------------------|---------|-------------| +| `{PREFIX}_FORWARDEDHEADERS_ENABLED` | `true` | Enable forwarded headers processing | +| `{PREFIX}_FORWARDEDHEADERS_TRUSTALLPROXIES` | `true` | Trust all proxies | +| `{PREFIX}_FORWARDEDHEADERS_KNOWNPROXIES` | - | Comma-separated list of trusted proxy IPs | +| `{PREFIX}_FORWARDEDHEADERS_KNOWNNETWORKS` | - | Comma-separated list of trusted CIDR ranges | +| `{PREFIX}_HTTPS_REDIRECTHTTPTOHTTPS` | `false` | Redirect HTTP to HTTPS | +| `{PREFIX}_HTTPS_PORT` | - | HTTPS port for redirect | +| `{PREFIX}_HTTPS_ENABLEHSTS` | `false` | Enable HSTS | +| `{PREFIX}_HTTPS_HSTSMAXAGESECONDS` | `31536000` | HSTS max-age (1 year) | +| `{PREFIX}_HTTPS_HSTSINCLUDESUBDOMAINS` | `false` | Include subdomains in HSTS | -```text -< HTTP/1.1 307 Temporary Redirect -< Location: https://servicecontrol.localhost/api +Where `{PREFIX}` is: + +- `SERVICECONTROL` for ServiceControl (Primary) +- `SERVICECONTROL_AUDIT` for ServiceControl.Audit +- `MONITORING` for ServiceControl.Monitoring + +## Cleanup + +### Stop NGINX + +```cmd +docker compose -f .local/compose.yml down ``` -The middleware detects `X-Forwarded-Proto: http` and redirects the client to the HTTPS URL. +### Clear Environment Variables -### Verify Without Redirection +After testing, clear the environment variables: -With `RedirectHttpToHttps` disabled (or not set), HTTP requests are processed normally: +**Command Prompt (cmd):** -```bash -# Request via HTTP - should receive 200 OK (no redirect) -curl -v http://servicecontrol.localhost/api 2>&1 | grep "< HTTP" +```cmd +set SERVICECONTROL_FORWARDEDHEADERS_ENABLED= +set SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES= +set SERVICECONTROL_HTTPS_REDIRECTHTTPTOHTTPS= +set SERVICECONTROL_HTTPS_PORT= +set SERVICECONTROL_HTTPS_ENABLEHSTS= ``` -**Expected output:** +**PowerShell:** -```text -< HTTP/1.1 200 OK +```powershell +$env:SERVICECONTROL_FORWARDEDHEADERS_ENABLED = $null +$env:SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES = $null +$env:SERVICECONTROL_HTTPS_REDIRECTHTTPTOHTTPS = $null +$env:SERVICECONTROL_HTTPS_PORT = $null +$env:SERVICECONTROL_HTTPS_ENABLEHSTS = $null ``` -### How It Works +### Remove Hosts Entries (Optional) -1. Client sends HTTP request to `http://servicecontrol.localhost/api` -2. NGINX receives on port 80 and forwards to ServiceControl with `X-Forwarded-Proto: http` -3. ServiceControl's forwarded headers middleware processes the header (from trusted proxy) -4. `Request.Scheme` is set to `http` based on `X-Forwarded-Proto` -5. HTTPS redirection middleware sees `Request.Scheme == "http"` and issues a 307 redirect -6. Client follows redirect to `https://servicecontrol.localhost/api` +If you no longer need the hostnames, remove these entries from your hosts file (`C:\Windows\System32\drivers\etc\hosts`): -> **Note:** This redirection only works with a reverse proxy because ServiceControl needs to receive the `X-Forwarded-Proto` header to know the original protocol. Without a proxy, ServiceControl only binds to a single port and cannot perform HTTP to HTTPS redirection. See [Local HTTPS Testing](local-https-testing.md) for direct HTTPS scenarios. +```text +127.0.0.1 servicecontrol.localhost +127.0.0.1 servicecontrol-audit.localhost +127.0.0.1 servicecontrol-monitor.localhost +``` ## Troubleshooting +### 502 Bad Gateway + +This error means NGINX cannot reach ServiceControl. Check: + +1. ServiceControl is running (`dotnet run` in the appropriate project directory) +2. ServiceControl is accessible directly: `curl http://localhost:33333/api` +3. Docker Desktop is running and `host.docker.internal` resolves correctly + ### "Connection refused" errors -Ensure the ServiceControl instances are running and listening on the expected ports. +Ensure ServiceControl instances are running and listening on the expected ports: + +- ServiceControl (Primary): 33333 +- ServiceControl.Audit: 44444 +- ServiceControl.Monitoring: 33633 ### Headers not being applied -1. Verify `ForwardedHeaders.Enabled` is `true` -2. Check that `KnownProxies` includes `127.0.0.1` -3. Review the ServiceControl logs for forwarded headers configuration messages +1. Verify `FORWARDEDHEADERS_ENABLED` is `true` +2. Verify `FORWARDEDHEADERS_TRUSTALLPROXIES` is `true` (for local Docker testing) +3. Use the `/debug/request-info` endpoint to check current settings ### Certificate errors in browser @@ -466,12 +525,11 @@ If using Docker Desktop on Windows with WSL2: - Ensure `host.docker.internal` resolves correctly - Check that the ServiceControl ports are not blocked by Windows Firewall -## Stopping the Environment +### Debug endpoint not available -```bash -docker compose -f .local/compose.yml down -``` +The `/debug/request-info` endpoint is only available when running in Development environment (the default when using `dotnet run`). ## See Also -- [Hosting Guide](hosting-guide.md) - Detailed configuration reference for all deployment scenarios +- [Hosting Guide](hosting-guide.md) - Configuration reference for all deployment scenarios +- [Local Forwarded Headers Testing](local-forward-headers-testing.md) - Testing forwarded headers without a reverse proxy diff --git a/src/ServiceControl.Audit/App.config b/src/ServiceControl.Audit/App.config index 6fc8a846e7..9c67595328 100644 --- a/src/ServiceControl.Audit/App.config +++ b/src/ServiceControl.Audit/App.config @@ -53,8 +53,9 @@ These settings are only here so that we can debug ServiceControl while developin - + + diff --git a/src/ServiceControl.Hosting/ForwardedHeaders/WebApplicationExtensions.cs b/src/ServiceControl.Hosting/ForwardedHeaders/WebApplicationExtensions.cs index adcce44281..bbf49d01c5 100644 --- a/src/ServiceControl.Hosting/ForwardedHeaders/WebApplicationExtensions.cs +++ b/src/ServiceControl.Hosting/ForwardedHeaders/WebApplicationExtensions.cs @@ -59,11 +59,18 @@ public static void UseServiceControlForwardedHeaders(this WebApplication app, Fo ForwardedHeaders = ForwardedHeaders.All }; - if (!settings.TrustAllProxies) - { - options.KnownProxies.Clear(); - options.KnownNetworks.Clear(); + // Clear default loopback-only restrictions + options.KnownProxies.Clear(); + options.KnownNetworks.Clear(); + if (settings.TrustAllProxies) + { + // Trust all proxies: remove hop limit + options.ForwardLimit = null; + } + else + { + // Only trust explicitly configured proxies and networks foreach (var proxy in settings.KnownProxies) { options.KnownProxies.Add(proxy); diff --git a/src/ServiceControl.Hosting/Https/HostApplicationBuilderExtensions.cs b/src/ServiceControl.Hosting/Https/HostApplicationBuilderExtensions.cs index 0a89c006d6..af7e63e938 100644 --- a/src/ServiceControl.Hosting/Https/HostApplicationBuilderExtensions.cs +++ b/src/ServiceControl.Hosting/Https/HostApplicationBuilderExtensions.cs @@ -21,6 +21,14 @@ public static void AddServiceControlHttps(this WebApplicationBuilder hostBuilder }); } + if (settings.RedirectHttpToHttps && settings.HttpsPort.HasValue) + { + hostBuilder.Services.AddHttpsRedirection(options => + { + options.HttpsPort = settings.HttpsPort.Value; + }); + } + if (settings.Enabled) { hostBuilder.WebHost.ConfigureKestrel(kestrel => diff --git a/src/ServiceControl.Infrastructure/HttpsSettings.cs b/src/ServiceControl.Infrastructure/HttpsSettings.cs index de7a116ab0..d613d2447d 100644 --- a/src/ServiceControl.Infrastructure/HttpsSettings.cs +++ b/src/ServiceControl.Infrastructure/HttpsSettings.cs @@ -24,6 +24,7 @@ public HttpsSettings(SettingsRootNamespace rootNamespace) // HTTPS redirection - disabled by default for backwards compatibility RedirectHttpToHttps = SettingsReader.Read(rootNamespace, "Https.RedirectHttpToHttps", false); + HttpsPort = SettingsReader.Read(rootNamespace, "Https.Port", null); // HSTS - disabled by default, only applies in non-development environments EnableHsts = SettingsReader.Read(rootNamespace, "Https.EnableHsts", false); @@ -52,6 +53,8 @@ public HttpsSettings(SettingsRootNamespace rootNamespace) public bool RedirectHttpToHttps { get; } + public int? HttpsPort { get; } + public bool EnableHsts { get; } public int HstsMaxAgeSeconds { get; } @@ -83,6 +86,7 @@ void LogConfiguration() logger.LogInformation(" CertificatePath: {CertificatePath}", CertificatePath); logger.LogInformation(" CertificatePassword: {CertificatePassword}", string.IsNullOrEmpty(CertificatePassword) ? "(not set)" : "(set)"); logger.LogInformation(" RedirectHttpToHttps: {RedirectHttpToHttps}", RedirectHttpToHttps); + logger.LogInformation(" HttpsPort: {HttpsPort}", HttpsPort?.ToString() ?? "(not set)"); logger.LogInformation(" EnableHsts: {EnableHsts}", EnableHsts); logger.LogInformation(" HstsMaxAgeSeconds: {HstsMaxAgeSeconds}", HstsMaxAgeSeconds); logger.LogInformation(" HstsIncludeSubDomains: {HstsIncludeSubDomains}", HstsIncludeSubDomains); diff --git a/src/ServiceControl.Monitoring/App.config b/src/ServiceControl.Monitoring/App.config index 6521e32445..4d3d8c6ed7 100644 --- a/src/ServiceControl.Monitoring/App.config +++ b/src/ServiceControl.Monitoring/App.config @@ -50,8 +50,9 @@ These settings are only here so that we can debug ServiceControl while developin - + + diff --git a/src/ServiceControl/App.config b/src/ServiceControl/App.config index 079f6f6adc..1f007f0123 100644 --- a/src/ServiceControl/App.config +++ b/src/ServiceControl/App.config @@ -61,8 +61,9 @@ These settings are only here so that we can debug ServiceControl while developin - + + From 4956f43b1271cfd1aa6a5f93c58a49e24d38dc0b Mon Sep 17 00:00:00 2001 From: Warwick Schroeder Date: Mon, 15 Dec 2025 15:02:06 +0800 Subject: [PATCH 20/24] Update documentation for authentication --- docs/authentication.md | 212 ++++++++++++++ docs/forwarded-headers.md | 132 +++++++++ docs/https-configuration.md | 106 +++++++ docs/local-authentication-testing.md | 396 +++++++++++++++++++++++++++ 4 files changed, 846 insertions(+) create mode 100644 docs/authentication.md create mode 100644 docs/forwarded-headers.md create mode 100644 docs/https-configuration.md create mode 100644 docs/local-authentication-testing.md diff --git a/docs/authentication.md b/docs/authentication.md new file mode 100644 index 0000000000..df1a5d5d2c --- /dev/null +++ b/docs/authentication.md @@ -0,0 +1,212 @@ +# Authentication Configuration + +ServiceControl instances can be configured to require JWT authentication using OpenID Connect (OIDC). This enables integration with identity providers like Microsoft Entra ID (Azure AD), Okta, Auth0, and other OIDC-compliant providers. + +## Configuration + +ServiceControl instances can be configured via environment variables or App.config. Each instance type uses a different prefix. + +### Environment Variables + +| Instance | Prefix | +|----------|--------| +| ServiceControl (Primary) | `SERVICECONTROL_` | +| ServiceControl.Audit | `SERVICECONTROL_AUDIT_` | +| ServiceControl.Monitoring | `MONITORING_` | + +#### Core Settings + +| Setting | Default | Description | +|---------|---------|-------------| +| `{PREFIX}AUTHENTICATION_ENABLED` | `false` | Enable JWT authentication | +| `{PREFIX}AUTHENTICATION_AUTHORITY` | (none) | OpenID Connect authority URL (e.g., `https://login.microsoftonline.com/{tenant-id}/v2.0`) | +| `{PREFIX}AUTHENTICATION_AUDIENCE` | (none) | The audience identifier (typically your API identifier or client ID) | + +#### Validation Settings + +| Setting | Default | Description | +|---------|---------|-------------| +| `{PREFIX}AUTHENTICATION_VALIDATEISSUER` | `true` | Validate the token issuer | +| `{PREFIX}AUTHENTICATION_VALIDATEAUDIENCE` | `true` | Validate the token audience | +| `{PREFIX}AUTHENTICATION_VALIDATELIFETIME` | `true` | Validate token expiration | +| `{PREFIX}AUTHENTICATION_VALIDATEISSUERSIGNINGKEY` | `true` | Validate the signing key | +| `{PREFIX}AUTHENTICATION_REQUIREHTTPSMETADATA` | `true` | Require HTTPS for OIDC metadata endpoint | + +#### ServicePulse Settings (Primary Instance Only) + +These settings are required on the primary ServiceControl instance to provide authentication configuration to ServicePulse clients. + +| Setting | Default | Description | +|---------|---------|-------------| +| `{PREFIX}AUTHENTICATION_SERVICEPULSE_CLIENTID` | (none) | Client ID for ServicePulse application | +| `{PREFIX}AUTHENTICATION_SERVICEPULSE_AUTHORITY` | (none) | Authority URL for ServicePulse (defaults to main Authority if not set) | +| `{PREFIX}AUTHENTICATION_SERVICEPULSE_APISCOPES` | (none) | JSON array of API scopes (e.g., `["api://servicecontrol/access_as_user"]`) | + +### App.config + +| Instance | Key Prefix | +|----------|------------| +| ServiceControl (Primary) | `ServiceControl/` | +| ServiceControl.Audit | `ServiceControl.Audit/` | +| ServiceControl.Monitoring | `Monitoring/` | + +```xml + + + + + + + + + + + + + + + + + +``` + +## Examples + +### Microsoft Entra ID (Azure AD) + +```cmd +set SERVICECONTROL_AUTHENTICATION_ENABLED=true +set SERVICECONTROL_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0 +set SERVICECONTROL_AUTHENTICATION_AUDIENCE=api://servicecontrol +set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID={servicepulse-client-id} +set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES=["api://servicecontrol/access_as_user"] +``` + +### Docker Example + +```cmd +docker run -p 33333:33333 -e SERVICECONTROL_AUTHENTICATION_ENABLED=true -e SERVICECONTROL_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0 -e SERVICECONTROL_AUTHENTICATION_AUDIENCE=api://servicecontrol -e SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID={servicepulse-client-id} -e "SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES=[\"api://servicecontrol/.default\"]" particular/servicecontrol:latest +``` + +### Audit and Monitoring Instances + +Audit and Monitoring instances don't require ServicePulse settings: + +```cmd +set SERVICECONTROL_AUDIT_AUTHENTICATION_ENABLED=true +set SERVICECONTROL_AUDIT_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0 +set SERVICECONTROL_AUDIT_AUTHENTICATION_AUDIENCE=api://servicecontrol +``` + +```cmd +set MONITORING_AUTHENTICATION_ENABLED=true +set MONITORING_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0 +set MONITORING_AUTHENTICATION_AUDIENCE=api://servicecontrol +``` + +## How It Works + +When authentication is enabled: + +1. Most API requests must include a valid JWT bearer token in the `Authorization` header +2. ServiceControl validates the token against the configured authority +3. The token must have the correct audience and not be expired +4. ServicePulse retrieves authentication configuration from the `/api/authentication/configuration` endpoint + +### Anonymous Endpoints + +The following endpoints are accessible without authentication, even when authentication is enabled: + +| Endpoint | Purpose | +|----------|---------| +| `/api` | API root/discovery - returns available endpoints and API information | +| `/api/authentication/configuration` | Returns authentication configuration for clients like ServicePulse | + +These endpoints must remain accessible so clients can discover API capabilities and obtain the authentication configuration needed to acquire tokens. + +### Request Flow + +```mermaid +flowchart TD + Client[Client Request] --> Header[Authorization: Bearer token] + Header --> Validate{ServiceControl validates token} + + Validate --> |Valid| Process[Request processed] + Validate --> |Invalid| Reject[401 Unauthorized] + + subgraph Validation + V1[Issuer matches Authority] + V2[Audience matches configured] + V3[Token not expired] + V4[Signature is valid] + end + + Validate -.-> Validation +``` + +## Security Considerations + +### HTTPS Recommended + +When authentication is enabled, HTTPS is strongly recommended for production deployments. Without HTTPS, JWT tokens are transmitted in plain text and can be intercepted by attackers. Use either: + +- **Direct HTTPS**: Configure ServiceControl with a certificate (see [HTTPS Configuration](https-configuration.md)) +- **Reverse Proxy**: Terminate SSL at a reverse proxy (NGINX, Traefik, cloud load balancer) + +### Production Settings + +The default validation settings are recommended for production: + +| Setting | Recommendation | +|---------|----------------| +| `ValidateIssuer` | `true` - Prevents tokens from untrusted issuers | +| `ValidateAudience` | `true` - Prevents tokens intended for other applications | +| `ValidateLifetime` | `true` - Prevents expired tokens | +| `ValidateIssuerSigningKey` | `true` - Ensures token signature is valid | +| `RequireHttpsMetadata` | `true` - Ensures OIDC metadata is fetched securely | + +### Development Settings + +For local development with a test identity provider, you may need to relax some settings: + +```cmd +set SERVICECONTROL_AUTHENTICATION_REQUIREHTTPSMETADATA=false +``` + +> **Warning:** Never disable validation settings in production. Doing so can expose your system to serious security vulnerabilities. + +### HTTPS Requirement + +When `RequireHttpsMetadata` is `true` (the default), the Authority URL must use HTTPS. This ensures that the OIDC metadata (including signing keys) is fetched over a secure connection. + +## Configuring Identity Providers + +### Microsoft Entra ID (Azure AD) + +1. Register an application in Azure AD for ServiceControl API +2. Register a separate application for ServicePulse (SPA) +3. Configure API permissions and expose an API scope +4. Set the Authority to `https://login.microsoftonline.com/{tenant-id}/v2.0` +5. Set the Audience to your API application ID URI (e.g., `api://servicecontrol`) + +### Other OIDC Providers + +ServiceControl works with any OIDC-compliant provider. Configure: + +- **Authority**: The issuer URL from your provider's OIDC discovery document +- **Audience**: The identifier configured for your API in the provider + +## Testing Other Instances + +The primary ServiceControl instance requires ServicePulse settings because it serves the `/api/auth/config` endpoint that ServicePulse uses to configure its authentication. Audit and Monitoring instances only need the core authentication settings. + +| Instance | Requires ServicePulse Settings | +|----------|-------------------------------| +| ServiceControl (Primary) | Yes | +| ServiceControl.Audit | No | +| ServiceControl.Monitoring | No | + +## See Also + +- [Forwarded Headers Configuration](forwarded-headers.md) - Configure forwarded headers when behind a reverse proxy +- [HTTPS Configuration](https-configuration.md) - Configure direct HTTPS diff --git a/docs/forwarded-headers.md b/docs/forwarded-headers.md new file mode 100644 index 0000000000..0a8435ef39 --- /dev/null +++ b/docs/forwarded-headers.md @@ -0,0 +1,132 @@ +# Forwarded Headers Configuration + +When ServiceControl instances are deployed behind a reverse proxy (like NGINX, Traefik, or a cloud load balancer) that terminates SSL/TLS, you need to configure forwarded headers so ServiceControl correctly understands the original client request. + +## Configuration + +ServiceControl instances can be configured via environment variables or App.config. Each instance type uses a different prefix. + +### Environment Variables + +| Instance | Prefix | +|----------|--------| +| ServiceControl (Primary) | `SERVICECONTROL_` | +| ServiceControl.Audit | `SERVICECONTROL_AUDIT_` | +| ServiceControl.Monitoring | `MONITORING_` | + +| Setting | Default | Description | +|---------|---------|-------------| +| `{PREFIX}FORWARDEDHEADERS_ENABLED` | `true` | Enable forwarded headers processing | +| `{PREFIX}FORWARDEDHEADERS_TRUSTALLPROXIES` | `true` | Trust all proxies (auto-disabled if known proxies/networks set) | +| `{PREFIX}FORWARDEDHEADERS_KNOWNPROXIES` | (none) | Comma-separated IP addresses of trusted proxies | +| `{PREFIX}FORWARDEDHEADERS_KNOWNNETWORKS` | (none) | Comma-separated CIDR networks (e.g., `10.0.0.0/8,172.16.0.0/12`) | + +### App.config + +| Instance | Key Prefix | +|----------|------------| +| ServiceControl (Primary) | `ServiceControl/` | +| ServiceControl.Audit | `ServiceControl.Audit/` | +| ServiceControl.Monitoring | `Monitoring/` | + +```xml + + + + + + +``` + +## Examples + +### Trust all proxies (default, suitable for containers) + +```cmd +docker run -p 33333:33333 -e SERVICECONTROL_FORWARDEDHEADERS_ENABLED=true particular/servicecontrol:latest +``` + +### Restrict to specific proxies + +```cmd +docker run -p 33333:33333 -e SERVICECONTROL_FORWARDEDHEADERS_ENABLED=true -e SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES=127.0.0.1,10.0.0.5 particular/servicecontrol:latest +``` + +### Restrict to specific networks + +```cmd +docker run -p 33333:33333 -e SERVICECONTROL_FORWARDEDHEADERS_ENABLED=true -e SERVICECONTROL_FORWARDEDHEADERS_KNOWNNETWORKS=10.0.0.0/8,172.16.0.0/12 particular/servicecontrol:latest +``` + +When `KNOWNPROXIES` or `KNOWNNETWORKS` are set, `TRUSTALLPROXIES` is automatically disabled. + +## What Headers Are Processed + +When enabled, ServiceControl processes: + +- `X-Forwarded-For` - Original client IP address +- `X-Forwarded-Proto` - Original protocol (http/https) +- `X-Forwarded-Host` - Original host header + +## HTTP to HTTPS Redirect + +When using a reverse proxy that terminates SSL, you can configure ServiceControl to redirect HTTP requests to HTTPS. This works in combination with forwarded headers: + +1. The reverse proxy forwards both HTTP and HTTPS requests to ServiceControl +2. The proxy sets `X-Forwarded-Proto` to indicate the original protocol +3. ServiceControl reads this header (via forwarded headers processing) +4. If the original request was HTTP and redirect is enabled, ServiceControl returns a redirect to HTTPS + +To enable HTTP to HTTPS redirect: + +```cmd +set SERVICECONTROL_HTTPS_REDIRECTHTTPTOHTTPS=true +set SERVICECONTROL_HTTPS_PORT=443 +``` + +Or in App.config: + +```xml + + + + +``` + +## Proxy Chain Behavior (ForwardLimit) + +When processing `X-Forwarded-For` headers with multiple IPs (proxy chains), the behavior depends on trust configuration: + +| Configuration | ForwardLimit | Behavior | +|---------------|--------------|----------| +| `TrustAllProxies = true` | `null` (no limit) | Processes all IPs, returns original client IP | +| `TrustAllProxies = false` | `1` (default) | Processes only the last proxy IP | + +For example, with `X-Forwarded-For: 203.0.113.50, 10.0.0.1, 192.168.1.1`: + +- **TrustAllProxies = true**: Returns `203.0.113.50` (original client) +- **TrustAllProxies = false**: Returns `192.168.1.1` (last proxy) + +## Security Considerations + +By default, `TrustAllProxies` is `true`, which is suitable for container deployments where the proxy is trusted infrastructure. For production deployments with untrusted networks, consider restricting to known proxies or networks to prevent header spoofing attacks. + +### Forwarded Headers Behavior + +When the proxy is trusted: + +- `Request.Scheme` will be set from `X-Forwarded-Proto` (e.g., `https`) +- `Request.Host` will be set from `X-Forwarded-Host` (e.g., `servicecontrol.example.com`) +- Client IP will be available from `X-Forwarded-For` + +When the proxy is **not** trusted (incorrect `KnownProxies`): + +- `X-Forwarded-*` headers are **ignored** (not applied to the request) +- `Request.Scheme` remains `http` +- `Request.Host` remains the internal hostname +- The request is still processed (not blocked) + +## See Also + +- [Local Forwarded Headers Testing](local-forward-headers-testing.md) - Test forwarded headers configuration with curl +- [Local Reverse Proxy Testing](local-reverseproxy-testing.md) - Guide for testing with NGINX reverse proxy locally diff --git a/docs/https-configuration.md b/docs/https-configuration.md new file mode 100644 index 0000000000..7b95283c5e --- /dev/null +++ b/docs/https-configuration.md @@ -0,0 +1,106 @@ +# HTTPS Configuration + +ServiceControl instances can be configured to use HTTPS directly, enabling encrypted connections without relying on a reverse proxy for SSL termination. + +## Configuration + +ServiceControl instances can be configured via environment variables or App.config. Each instance type uses a different prefix. + +### Environment Variables + +| Instance | Prefix | +|----------|--------| +| ServiceControl (Primary) | `SERVICECONTROL_` | +| ServiceControl.Audit | `SERVICECONTROL_AUDIT_` | +| ServiceControl.Monitoring | `MONITORING_` | + +| Setting | Default | Description | +|---------|---------|-------------| +| `{PREFIX}HTTPS_ENABLED` | `false` | Enable HTTPS with Kestrel | +| `{PREFIX}HTTPS_CERTIFICATEPATH` | (none) | Path to the certificate file (.pfx) | +| `{PREFIX}HTTPS_CERTIFICATEPASSWORD` | (none) | Password for the certificate file | +| `{PREFIX}HTTPS_REDIRECTHTTPTOHTTPS` | `false` | Redirect HTTP requests to HTTPS | +| `{PREFIX}HTTPS_PORT` | (none) | HTTPS port for redirect (required for reverse proxy scenarios) | +| `{PREFIX}HTTPS_ENABLEHSTS` | `false` | Enable HTTP Strict Transport Security | +| `{PREFIX}HTTPS_HSTSMAXAGESECONDS` | `31536000` | HSTS max-age in seconds (default: 1 year) | +| `{PREFIX}HTTPS_HSTSINCLUDESUBDOMAINS` | `false` | Include subdomains in HSTS policy | + +### App.config + +| Instance | Key Prefix | +|----------|------------| +| ServiceControl (Primary) | `ServiceControl/` | +| ServiceControl.Audit | `ServiceControl.Audit/` | +| ServiceControl.Monitoring | `Monitoring/` | + +```xml + + + + + + +``` + +## Examples + +### Direct HTTPS with certificate + +```cmd +set SERVICECONTROL_HTTPS_ENABLED=true +set SERVICECONTROL_HTTPS_CERTIFICATEPATH=C:\certs\servicecontrol.pfx +set SERVICECONTROL_HTTPS_CERTIFICATEPASSWORD=mycertpassword +``` + +### Docker Example + +```cmd +docker run -p 33333:33333 -e SERVICECONTROL_HTTPS_ENABLED=true -e SERVICECONTROL_HTTPS_CERTIFICATEPATH=/certs/servicecontrol.pfx -e SERVICECONTROL_HTTPS_CERTIFICATEPASSWORD=mycertpassword -v C:\certs:/certs:ro particular/servicecontrol:latest +``` + +### Reverse proxy with HTTP to HTTPS redirect + +When using a reverse proxy that terminates SSL: + +```cmd +set SERVICECONTROL_FORWARDEDHEADERS_ENABLED=true +set SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES=true +set SERVICECONTROL_HTTPS_REDIRECTHTTPTOHTTPS=true +set SERVICECONTROL_HTTPS_PORT=443 +``` + +## Security Considerations + +### Certificate Security + +- Store certificate files securely with appropriate file permissions +- Use strong passwords for certificate files +- Rotate certificates before expiration +- Use certificates from a trusted Certificate Authority for production + +### HSTS Considerations + +- HSTS should not be tested on localhost because browsers cache the policy, which could break other local development +- HSTS is disabled in Development environment (ASP.NET Core excludes localhost by default) +- HSTS can be configured at either the reverse proxy level or in ServiceControl (but not both) +- HSTS is cached by browsers, so test carefully before enabling in production +- Start with a short max-age during initial deployment +- Consider the impact on subdomains before enabling `includeSubDomains` +- To test HSTS locally, use the [NGINX reverse proxy setup](local-reverseproxy-testing.md) with a custom hostname + +### HTTP to HTTPS Redirect + +The `HTTPS_REDIRECTHTTPTOHTTPS` setting is intended for use with a reverse proxy that handles both HTTP and HTTPS traffic. When enabled: + +- The redirect uses HTTP 307 (Temporary Redirect) to preserve the request method +- The reverse proxy must forward both HTTP and HTTPS requests to ServiceControl +- ServiceControl will redirect HTTP requests to HTTPS based on the `X-Forwarded-Proto` header +- **Important:** You must also set `HTTPS_PORT` to specify the HTTPS port for the redirect URL + +> **Note:** When running ServiceControl directly without a reverse proxy, the application only listens on a single protocol (HTTP or HTTPS). To test HTTP-to-HTTPS redirection locally, use the [NGINX reverse proxy setup](local-reverseproxy-testing.md). + +## See Also + +- [Local HTTPS Testing](local-https-testing.md) - Guide for testing HTTPS locally during development +- [Local Reverse Proxy Testing](local-reverseproxy-testing.md) - Testing with NGINX reverse proxy (HSTS, HTTP to HTTPS redirect) +- [Forwarded Headers Configuration](forwarded-headers.md) - Configure forwarded headers when behind a reverse proxy diff --git a/docs/local-authentication-testing.md b/docs/local-authentication-testing.md new file mode 100644 index 0000000000..14d4e23a08 --- /dev/null +++ b/docs/local-authentication-testing.md @@ -0,0 +1,396 @@ +# Local Testing Authentication + +This guide explains how to test authentication configuration for ServiceControl instances. This approach uses curl to test authentication enforcement and configuration endpoints. + +## Prerequisites + +- ServiceControl built locally (see main README for build instructions) +- curl (included with Windows 10/11, Git Bash, or WSL) +- (Optional) For formatted JSON output: `npm install -g json` then pipe curl output through `| json` +- (Optional) An OIDC provider for full end-to-end testing (e.g., Microsoft Entra ID, Auth0, Okta) + +## Instance Reference + +| Instance | Project Directory | Default Port | Environment Variable Prefix | +|----------|-------------------|--------------|----------------------------| +| ServiceControl (Primary) | `src\ServiceControl` | 33333 | `SERVICECONTROL_` | +| ServiceControl.Audit | `src\ServiceControl.Audit` | 44444 | `SERVICECONTROL_AUDIT_` | +| ServiceControl.Monitoring | `src\ServiceControl.Monitoring` | 33633 | `MONITORING_` | + +## How Authentication Works + +When authentication is enabled: + +1. All API requests must include a valid JWT bearer token in the `Authorization` header +2. ServiceControl validates the token against the configured OIDC authority +3. Requests without a valid token receive a `401 Unauthorized` response +4. The `/api/authentication/configuration` endpoint returns authentication configuration for clients (like ServicePulse) + +## Configuration Methods + +Settings can be configured via: + +1. **Environment variables** (recommended for testing) - Easy to change between scenarios, no file edits needed +2. **App.config** - Persisted settings, requires app restart after changes + +Both methods work identically. This guide uses environment variables for convenience during iterative testing. + +## Test Scenarios + +The following scenarios use ServiceControl (Primary) as an example. To test other instances, use the appropriate environment variable prefix and port. + +> **Important:** Set environment variables in the same terminal where you run `dotnet run`. Environment variables are scoped to the terminal session. +> +> **Tip:** Check the application startup logs to verify which settings were applied. The authentication configuration is logged at startup. + +### Scenario 1: Authentication Disabled (Default) + +Test the default behavior where authentication is disabled and all requests are allowed. + +**Clear environment variables and start ServiceControl:** + +```cmd +set SERVICECONTROL_AUTHENTICATION_ENABLED= +set SERVICECONTROL_AUTHENTICATION_AUTHORITY= +set SERVICECONTROL_AUTHENTICATION_AUDIENCE= +set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID= +set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES= +set SERVICECONTROL_AUTHENTICATION_REQUIREHTTPSMETADATA= +set SERVICECONTROL_AUTHENTICATION_VALIDATEISSUER= +set SERVICECONTROL_AUTHENTICATION_VALIDATEAUDIENCE= + +cd src\ServiceControl +dotnet run +``` + +**Test with curl (no authorization header):** + +```cmd +curl http://localhost:33333/api | json +``` + +**Expected output:** + +```json +{ + "description": "The management backend for the Particular Service Platform", + ... +} +``` + +Requests succeed without authentication because `Authentication.Enabled` defaults to `false`. + +**Check authentication configuration endpoint:** + +```cmd +curl http://localhost:33333/api/authentication/configuration | json +``` + +**Expected output:** + +```json +{ + "enabled": false +} +``` + +The configuration indicates authentication is disabled. Other fields are omitted when null. + +### Scenario 2: Authentication Enabled (No Token) + +Test that requests without a token are rejected when authentication is enabled. + +> **Note:** This scenario requires a valid OIDC authority URL. For testing authentication enforcement without a real provider, you can use any HTTP URL - the request will fail before token validation because no token is provided. + +**Clear environment variables and start ServiceControl:** + +```cmd +set SERVICECONTROL_AUTHENTICATION_ENABLED=true +set SERVICECONTROL_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/common/v2.0 +set SERVICECONTROL_AUTHENTICATION_AUDIENCE=api://servicecontrol-test +set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID=test-client-id +set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES=["api://servicecontrol-test/.default"] +set SERVICECONTROL_AUTHENTICATION_REQUIREHTTPSMETADATA= +set SERVICECONTROL_AUTHENTICATION_VALIDATEISSUER= +set SERVICECONTROL_AUTHENTICATION_VALIDATEAUDIENCE= + +cd src\ServiceControl +dotnet run +``` + +**Test with curl (no authorization header):** + +```cmd +curl -v http://localhost:33333/api/endpoints 2>&1 | findstr /C:"HTTP/" +``` + +**Expected output:** + +```text +< HTTP/1.1 401 Unauthorized +``` + +Requests without a token are rejected with `401 Unauthorized`. + +> **Note:** The `/api` root endpoint and `/api/authentication/configuration` are marked as anonymous and will return 200 OK even with authentication enabled. Test protected endpoints like `/api/endpoints` to verify authentication enforcement. + +**Check authentication configuration endpoint (no auth required):** + +```cmd +curl http://localhost:33333/api/authentication/configuration | json +``` + +**Expected output:** + +```json +{ + "enabled": true, + "clientId": "test-client-id", + "audience": "api://servicecontrol-test", + "apiScopes": "[\"api://servicecontrol-test/.default\"]" +} +``` + +The authentication configuration endpoint is accessible without authentication and returns the configuration that clients need to authenticate. The `authority` field is omitted when `ServicePulse.Authority` is not explicitly set (it defaults to the main Authority for ServicePulse clients). + +### Scenario 3: Authentication with Invalid Token + +Test that requests with an invalid token are rejected. + +**Start ServiceControl with authentication enabled (same as Scenario 2):** + +```cmd +set SERVICECONTROL_AUTHENTICATION_ENABLED=true +set SERVICECONTROL_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/common/v2.0 +set SERVICECONTROL_AUTHENTICATION_AUDIENCE=api://servicecontrol-test +set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID=test-client-id +set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES=["api://servicecontrol-test/.default"] +set SERVICECONTROL_AUTHENTICATION_REQUIREHTTPSMETADATA= +set SERVICECONTROL_AUTHENTICATION_VALIDATEISSUER= +set SERVICECONTROL_AUTHENTICATION_VALIDATEAUDIENCE= + +cd src\ServiceControl +dotnet run +``` + +**Test with curl (invalid token):** + +```cmd +curl -v -H "Authorization: Bearer invalid-token-here" http://localhost:33333/api/endpoints 2>&1 | findstr /C:"HTTP/" +``` + +**Expected output:** + +```text +< HTTP/1.1 401 Unauthorized +``` + +Invalid tokens are rejected with `401 Unauthorized`. + +### Scenario 4: Anonymous Endpoints + +Test that anonymous endpoints remain accessible when authentication is enabled. + +**With ServiceControl still running from Scenario 2 or 3, test anonymous endpoints:** + +```cmd +curl http://localhost:33333/api | json +``` + +**Expected output:** + +```json +{ + "description": "The management backend for the Particular Service Platform", + ... +} +``` + +```cmd +curl http://localhost:33333/api/authentication/configuration | json +``` + +**Expected output:** + +```json +{ + "enabled": true, + "clientId": "test-client-id", + "audience": "api://servicecontrol-test", + "apiScopes": "[\"api://servicecontrol-test/.default\"]" +} +``` + +The following endpoints are marked as anonymous and accessible without authentication: + +| Endpoint | Purpose | +|----------|---------| +| `/api` | API root/discovery - returns available endpoints | +| `/api/authentication/configuration` | Returns auth config for clients like ServicePulse | + +### Scenario 5: Validation Settings Warnings + +Test that disabling validation settings produces warnings in the logs. + +**Start ServiceControl with relaxed validation:** + +```cmd +set SERVICECONTROL_AUTHENTICATION_ENABLED=true +set SERVICECONTROL_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/common/v2.0 +set SERVICECONTROL_AUTHENTICATION_AUDIENCE=api://servicecontrol-test +set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID=test-client-id +set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES=["api://servicecontrol-test/.default"] +set SERVICECONTROL_AUTHENTICATION_REQUIREHTTPSMETADATA= +set SERVICECONTROL_AUTHENTICATION_VALIDATEISSUER=false +set SERVICECONTROL_AUTHENTICATION_VALIDATEAUDIENCE=false + +cd src\ServiceControl +dotnet run +``` + +**Expected log output:** + +```text +warn: Authentication.ValidateIssuer is set to false. This is not recommended for production environments... +warn: Authentication.ValidateAudience is set to false. This is not recommended for production environments... +``` + +The application warns about insecure validation settings. + +### Scenario 6: Missing Required Settings + +Test that missing required settings prevent startup. + +**Start ServiceControl with missing authority:** + +```cmd +set SERVICECONTROL_AUTHENTICATION_ENABLED=true +set SERVICECONTROL_AUTHENTICATION_AUTHORITY= +set SERVICECONTROL_AUTHENTICATION_AUDIENCE=api://servicecontrol-test +set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID=test-client-id +set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES=["api://servicecontrol-test/.default"] +set SERVICECONTROL_AUTHENTICATION_REQUIREHTTPSMETADATA= +set SERVICECONTROL_AUTHENTICATION_VALIDATEISSUER= +set SERVICECONTROL_AUTHENTICATION_VALIDATEAUDIENCE= + +cd src\ServiceControl +dotnet run +``` + +**Expected behavior:** + +The application fails to start with an error message: + +```text +Authentication.Authority is required when authentication is enabled. Please provide a valid OpenID Connect authority URL... +``` + +### Scenario 7: Authentication with Valid Token (Real Identity Provider) + +Test end-to-end authentication with a valid token from a real OIDC provider. + +> **Prerequisites:** This scenario requires a configured OIDC provider (e.g., Microsoft Entra ID, Auth0, Okta). + +**Microsoft Entra ID Setup (one-time):** + +1. **Create an App Registration** for ServiceControl API: + - Go to Azure Portal > Microsoft Entra ID > App registrations + - Create a new registration (e.g., "ServiceControl API") + - Note the Application (client) ID and Directory (tenant) ID + - Under "Expose an API", add a scope (e.g., `access_as_user`) + +2. **Create an App Registration** for testing (or use ServicePulse's): + - Create another registration for the client application + - Under "API permissions", add permission to your ServiceControl API scope + - Under "Authentication", enable "Allow public client flows" for testing + +**Start ServiceControl with your Entra ID configuration:** + +```cmd +set SERVICECONTROL_AUTHENTICATION_ENABLED=true +set SERVICECONTROL_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0 +set SERVICECONTROL_AUTHENTICATION_AUDIENCE=api://servicecontrol +set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID={servicepulse-client-id} +set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES=["api://servicecontrol/access_as_user"] +set SERVICECONTROL_AUTHENTICATION_REQUIREHTTPSMETADATA= +set SERVICECONTROL_AUTHENTICATION_VALIDATEISSUER= +set SERVICECONTROL_AUTHENTICATION_VALIDATEAUDIENCE= + +cd src\ServiceControl +dotnet run +``` + +**Get a test token using Azure CLI:** + +```cmd +az login +az account get-access-token --resource api://servicecontrol --query accessToken -o tsv +``` + +**Test with the token:** + +```cmd +curl -H "Authorization: Bearer {token}" http://localhost:33333/api/endpoints | json +``` + +**Expected output:** + +```json +[] +``` + +Requests with a valid token are processed successfully. The response will be an empty array if no endpoints are registered, or a list of endpoints if data exists. + +## Testing Other Instances + +The scenarios above use ServiceControl (Primary). To test ServiceControl.Audit or ServiceControl.Monitoring: + +1. Use the appropriate environment variable prefix (see Instance Reference above) +2. Use the corresponding project directory and port +3. Note: Audit and Monitoring instances don't require ServicePulse settings + +| Instance | Project Directory | Port | Env Var Prefix | +|----------|-------------------|------|----------------| +| ServiceControl (Primary) | `src\ServiceControl` | 33333 | `SERVICECONTROL_` | +| ServiceControl.Audit | `src\ServiceControl.Audit` | 44444 | `SERVICECONTROL_AUDIT_` | +| ServiceControl.Monitoring | `src\ServiceControl.Monitoring` | 33633 | `MONITORING_` | + +## Cleanup + +After testing, clear the environment variables: + +**Command Prompt (cmd):** + +```cmd +set SERVICECONTROL_AUTHENTICATION_ENABLED= +set SERVICECONTROL_AUTHENTICATION_AUTHORITY= +set SERVICECONTROL_AUTHENTICATION_AUDIENCE= +set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID= +set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES= +set SERVICECONTROL_AUTHENTICATION_VALIDATEISSUER= +set SERVICECONTROL_AUTHENTICATION_VALIDATEAUDIENCE= +set SERVICECONTROL_AUTHENTICATION_VALIDATELIFETIME= +set SERVICECONTROL_AUTHENTICATION_VALIDATEISSUERSIGNINGKEY= +set SERVICECONTROL_AUTHENTICATION_REQUIREHTTPSMETADATA= +``` + +**PowerShell:** + +```powershell +$env:SERVICECONTROL_AUTHENTICATION_ENABLED = $null +$env:SERVICECONTROL_AUTHENTICATION_AUTHORITY = $null +$env:SERVICECONTROL_AUTHENTICATION_AUDIENCE = $null +$env:SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID = $null +$env:SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES = $null +$env:SERVICECONTROL_AUTHENTICATION_VALIDATEISSUER = $null +$env:SERVICECONTROL_AUTHENTICATION_VALIDATEAUDIENCE = $null +$env:SERVICECONTROL_AUTHENTICATION_VALIDATELIFETIME = $null +$env:SERVICECONTROL_AUTHENTICATION_VALIDATEISSUERSIGNINGKEY = $null +$env:SERVICECONTROL_AUTHENTICATION_REQUIREHTTPSMETADATA = $null +``` + +## See Also + +- [Authentication Configuration](authentication.md) - Configuration reference for authentication settings +- [HTTPS Configuration](https-configuration.md) - HTTPS is recommended when authentication is enabled +- [Local Forwarded Headers Testing](local-forward-headers-testing.md) - Testing forwarded headers From 91764a075e28d52f1f5c554210974a042774ffb3 Mon Sep 17 00:00:00 2001 From: Warwick Schroeder Date: Tue, 16 Dec 2025 14:54:51 +0800 Subject: [PATCH 21/24] Add forward header tests for all instances. Add links to additional documentation in readme --- README.md | 17 +- docs/local-forward-headers-testing.md | 75 +++ docs/testing-architecture.md | 527 ++++++++++++++++++ .../ForwardedHeadersAssertions.cs | 271 +++++++++ .../ForwardedHeadersTestConfiguration.cs | 136 +++++ .../ForwardedHeaders/RequestInfoResponse.cs | 60 ++ ...ned_proxies_and_networks_are_configured.cs | 65 +++ .../When_forwarded_headers_are_disabled.cs | 60 ++ .../When_forwarded_headers_are_sent.cs | 44 ++ .../When_known_networks_are_configured.cs | 63 +++ .../When_known_proxies_are_configured.cs | 63 +++ .../When_only_proto_header_is_sent.cs | 38 ++ .../When_proxy_chain_headers_are_sent.cs | 48 ++ ...ain_headers_are_sent_with_known_proxies.cs | 64 +++ .../When_request_has_no_forwarded_headers.cs | 40 ++ .../When_unknown_network_sends_headers.cs | 68 +++ .../When_unknown_proxy_sends_headers.cs | 67 +++ .../ServiceControlComponentRunner.cs | 18 + ...ned_proxies_and_networks_are_configured.cs | 65 +++ .../When_forwarded_headers_are_disabled.cs | 60 ++ .../When_forwarded_headers_are_sent.cs | 44 ++ .../When_known_networks_are_configured.cs | 63 +++ .../When_known_proxies_are_configured.cs | 63 +++ .../When_only_proto_header_is_sent.cs | 38 ++ .../When_proxy_chain_headers_are_sent.cs | 48 ++ ...ain_headers_are_sent_with_known_proxies.cs | 64 +++ .../When_request_has_no_forwarded_headers.cs | 40 ++ .../When_unknown_network_sends_headers.cs | 68 +++ .../When_unknown_proxy_sends_headers.cs | 67 +++ .../ServiceControlComponentRunner.cs | 18 + ...rovals.PlatformSampleSettings.approved.txt | 1 + ...ned_proxies_and_networks_are_configured.cs | 65 +++ .../When_forwarded_headers_are_disabled.cs | 60 ++ .../When_forwarded_headers_are_sent.cs | 44 ++ .../When_known_networks_are_configured.cs | 63 +++ .../When_known_proxies_are_configured.cs | 63 +++ .../When_only_proto_header_is_sent.cs | 38 ++ .../When_proxy_chain_headers_are_sent.cs | 48 ++ ...ain_headers_are_sent_with_known_proxies.cs | 64 +++ .../When_request_has_no_forwarded_headers.cs | 40 ++ .../When_unknown_network_sends_headers.cs | 68 +++ .../When_unknown_proxy_sends_headers.cs | 67 +++ .../ServiceControlComponentRunner.cs | 18 + ...sTests.PlatformSampleSettings.approved.txt | 1 + ...rovals.PlatformSampleSettings.approved.txt | 1 + .../Settings/ForwardedHeadersSettingsTests.cs | 163 ++++++ 46 files changed, 3165 insertions(+), 1 deletion(-) create mode 100644 docs/testing-architecture.md create mode 100644 src/ServiceControl.AcceptanceTesting/ForwardedHeaders/ForwardedHeadersAssertions.cs create mode 100644 src/ServiceControl.AcceptanceTesting/ForwardedHeaders/ForwardedHeadersTestConfiguration.cs create mode 100644 src/ServiceControl.AcceptanceTesting/ForwardedHeaders/RequestInfoResponse.cs create mode 100644 src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_combined_proxies_and_networks_are_configured.cs create mode 100644 src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_forwarded_headers_are_disabled.cs create mode 100644 src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_forwarded_headers_are_sent.cs create mode 100644 src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_known_networks_are_configured.cs create mode 100644 src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_known_proxies_are_configured.cs create mode 100644 src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_only_proto_header_is_sent.cs create mode 100644 src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_proxy_chain_headers_are_sent.cs create mode 100644 src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_proxy_chain_headers_are_sent_with_known_proxies.cs create mode 100644 src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_request_has_no_forwarded_headers.cs create mode 100644 src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_unknown_network_sends_headers.cs create mode 100644 src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_unknown_proxy_sends_headers.cs create mode 100644 src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_combined_proxies_and_networks_are_configured.cs create mode 100644 src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_forwarded_headers_are_disabled.cs create mode 100644 src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_forwarded_headers_are_sent.cs create mode 100644 src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_known_networks_are_configured.cs create mode 100644 src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_known_proxies_are_configured.cs create mode 100644 src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_only_proto_header_is_sent.cs create mode 100644 src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_proxy_chain_headers_are_sent.cs create mode 100644 src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_proxy_chain_headers_are_sent_with_known_proxies.cs create mode 100644 src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_request_has_no_forwarded_headers.cs create mode 100644 src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_unknown_network_sends_headers.cs create mode 100644 src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_unknown_proxy_sends_headers.cs create mode 100644 src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_combined_proxies_and_networks_are_configured.cs create mode 100644 src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_forwarded_headers_are_disabled.cs create mode 100644 src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_forwarded_headers_are_sent.cs create mode 100644 src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_known_networks_are_configured.cs create mode 100644 src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_known_proxies_are_configured.cs create mode 100644 src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_only_proto_header_is_sent.cs create mode 100644 src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_proxy_chain_headers_are_sent.cs create mode 100644 src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_proxy_chain_headers_are_sent_with_known_proxies.cs create mode 100644 src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_request_has_no_forwarded_headers.cs create mode 100644 src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_unknown_network_sends_headers.cs create mode 100644 src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_unknown_proxy_sends_headers.cs create mode 100644 src/ServiceControl.UnitTests/Infrastructure/Settings/ForwardedHeadersSettingsTests.cs diff --git a/README.md b/README.md index 2927d8dcf6..714d2c0e20 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ It's also possible to [locally test containers built from PRs in GitHub Containe ### Infrastructure setup If the instance is executed for the first time, it must set up the required infrastructure. To do so, once the instance is configured to use the selected transport and persister, run it in setup mode. This can be done by using the `Setup {instance name}` launch profile that is defined in -the `launchSettings.json` file of each instance. When started in setup mode, the instance will start as usual, execute the setup process, and exit. At this point the instance can be run normally by using the non-setup launch profile. +the `launchSettings.json` file of each instance. When started in setup mode, the instance will start as usual, execute the setup process, and exit. At this point the instance can be run normally by using the non-setup launch profile. ## Secrets @@ -56,6 +56,21 @@ Running all tests all the times takes a lot of resources. Tests are filtered bas NOTE: If no variable is defined all tests will be executed. +## Security Configuration + +Documentation for configuring security features: + +- [HTTPS Configuration](docs/https-configuration.md) - Configure HTTPS/TLS for secure connections +- [Forwarded Headers](docs/forwarded-headers.md) - Configure X-Forwarded-* header handling for reverse proxy scenarios +- [Authentication](docs/authentication.md) - Configure authentication for the HTTP API + +Local testing guides: + +- [Local HTTPS Testing](docs/local-https-testing.md) +- [Local Reverse Proxy Testing](docs/local-reverseproxy-testing.md) +- [Local Forward Headers Testing](docs/local-forward-headers-testing.md) +- [Local Authentication Testing](docs/local-authentication-testing.md) + ## How to developer test the PowerShell Module Steps: diff --git a/docs/local-forward-headers-testing.md b/docs/local-forward-headers-testing.md index 77aff04462..dcf3ef361c 100644 --- a/docs/local-forward-headers-testing.md +++ b/docs/local-forward-headers-testing.md @@ -759,7 +759,82 @@ dotnet run curl -H "X-Forwarded-Proto: https" http://localhost:33633/debug/request-info | json ``` +## Unit Tests + +Unit tests for the `ForwardedHeadersSettings` configuration class are located at: + +```text +src/ServiceControl.UnitTests/Infrastructure/Settings/ForwardedHeadersSettingsTests.cs +``` + +### Running the Tests + +```bash +dotnet test src/ServiceControl.UnitTests/ServiceControl.UnitTests.csproj --filter "FullyQualifiedName~ForwardedHeadersSettingsTests" +``` + +### What the Tests Cover + +| Test | Purpose | +|------|---------| +| `Should_parse_known_proxies_from_comma_separated_list` | Verifies parsing of multiple proxy IPs | +| `Should_parse_known_proxies_to_ip_addresses` | Verifies `KnownProxies` property returns valid `IPAddress` objects | +| `Should_ignore_invalid_ip_addresses` | Verifies invalid IPs are filtered out gracefully | +| `Should_parse_known_networks_from_comma_separated_cidr` | Verifies CIDR notation parsing | +| `Should_ignore_invalid_network_cidr` | Verifies invalid CIDR entries are filtered | +| `Should_disable_trust_all_proxies_when_known_proxies_configured` | Verifies auto-disable behavior | +| `Should_disable_trust_all_proxies_when_known_networks_configured` | Verifies auto-disable behavior | +| `Should_default_to_enabled` | Verifies default value | +| `Should_default_to_trust_all_proxies` | Verifies default value | +| `Should_respect_explicit_disabled_setting` | Verifies explicit configuration | +| `Should_handle_semicolon_separator_in_proxies` | Tests alternate separator | +| `Should_trim_whitespace_from_proxy_entries` | Tests whitespace handling | + +## Acceptance Tests + +Acceptance tests for end-to-end forwarded headers behavior are located at: + +```text +src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/ +src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/ +src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/ +``` + +Each instance type has identical tests covering all scenarios. + +### Running the Tests + +```bash +# ServiceControl (Primary) +dotnet test src/ServiceControl.AcceptanceTests.RavenDB/ServiceControl.AcceptanceTests.RavenDB.csproj --filter "FullyQualifiedName~ForwardedHeaders" + +# ServiceControl.Audit +dotnet test src/ServiceControl.Audit.AcceptanceTests/ServiceControl.Audit.AcceptanceTests.csproj --filter "FullyQualifiedName~ForwardedHeaders" + +# ServiceControl.Monitoring +dotnet test src/ServiceControl.Monitoring.AcceptanceTests/ServiceControl.Monitoring.AcceptanceTests.csproj --filter "FullyQualifiedName~ForwardedHeaders" +``` + +### Scenarios Covered + +| Scenario | Test | +|----------|------| +| 0 | `When_request_has_no_forwarded_headers` | +| 1/2 | `When_forwarded_headers_are_sent` | +| 3 | `When_known_proxies_are_configured` | +| 4 | `When_known_networks_are_configured` | +| 5 | `When_unknown_proxy_sends_headers` | +| 6 | `When_unknown_network_sends_headers` | +| 7 | `When_forwarded_headers_are_disabled` | +| 8 | `When_proxy_chain_headers_are_sent` | +| 9 | `When_proxy_chain_headers_are_sent_with_known_proxies` | +| 10 | `When_combined_proxies_and_networks_are_configured` | +| 11 | `When_only_proto_header_is_sent` | + +> **Note:** Scenario 12 (IPv4/IPv6 Mismatch) is not covered by acceptance tests because the test server's IP address (IPv4 vs IPv6) cannot be controlled reliably. The "untrusted proxy" behavior is already validated by Scenarios 5 and 6. + ## See Also - [Hosting Guide](hosting-guide.md) - Configuration reference for forwarded headers - [Local Reverse Proxy Testing](local-reverseproxy-testing.md) - Testing with a real reverse proxy (NGINX) +- [Testing Architecture](testing-architecture.md) - Overview of testing patterns in this repository diff --git a/docs/testing-architecture.md b/docs/testing-architecture.md new file mode 100644 index 0000000000..b81408ed5f --- /dev/null +++ b/docs/testing-architecture.md @@ -0,0 +1,527 @@ +# Testing Architecture + +This document provides a comprehensive overview of the testing architecture in ServiceControl. It is intended to help developers understand how tests are structured and where to add tests for new functionality. + +For a summary of test types, see [testing.md](testing.md). For manual testing scenarios, see [testing-scenarios.md](testing-scenarios.md). + +## Test Projects Overview + +The repository contains 28 test projects organized into several categories: + +### Unit Test Projects + +| Project | Purpose | +|---------|---------| +| `ServiceControl.UnitTests` | Primary instance unit tests | +| `ServiceControl.Audit.UnitTests` | Audit instance unit tests | +| `ServiceControl.Monitoring.UnitTests` | Monitoring instance unit tests | +| `ServiceControl.Infrastructure.Tests` | Shared infrastructure tests | +| `ServiceControl.Config.Tests` | WPF configuration UI tests | +| `ServiceControlInstaller.Engine.UnitTests` | Windows service installer tests | +| `ServiceControlInstaller.Packaging.UnitTests` | Packaging utilities tests | +| `Particular.LicensingComponent.UnitTests` | Licensing component tests | + +### Persistence Test Projects + +| Project | Purpose | +|---------|---------| +| `ServiceControl.Persistence.Tests` | Abstract persistence layer tests | +| `ServiceControl.Persistence.Tests.RavenDB` | RavenDB persistence implementation | +| `ServiceControl.Persistence.Tests.InMemory` | In-memory persistence tests | +| `ServiceControl.Audit.Persistence.Tests` | Audit persistence abstractions | +| `ServiceControl.Audit.Persistence.Tests.RavenDB` | Audit RavenDB tests | + +### Acceptance Test Projects + +| Project | Purpose | +|---------|---------| +| `ServiceControl.AcceptanceTests` | Primary instance shared acceptance test code | +| `ServiceControl.AcceptanceTests.RavenDB` | Primary instance with RavenDB | +| `ServiceControl.Audit.AcceptanceTests` | Audit instance shared acceptance test code | +| `ServiceControl.Audit.AcceptanceTests.RavenDB` | Audit with RavenDB | +| `ServiceControl.Monitoring.AcceptanceTests` | Monitoring instance acceptance tests | +| `ServiceControl.MultiInstance.AcceptanceTests` | Multi-instance integration tests | + +### Transport Test Projects + +| Project | Filter Value | +|---------|--------------| +| `ServiceControl.Transports.Tests` | Default (Learning Transport) | +| `ServiceControl.Transports.ASBS.Tests` | AzureServiceBus | +| `ServiceControl.Transports.ASQ.Tests` | AzureStorageQueues | +| `ServiceControl.Transports.Msmq.Tests` | MSMQ | +| `ServiceControl.Transports.PostgreSql.Tests` | PostgreSql | +| `ServiceControl.Transports.RabbitMQClassicConventionalRouting.Tests` | RabbitMQ | +| `ServiceControl.Transports.RabbitMQClassicDirectRouting.Tests` | RabbitMQ | +| `ServiceControl.Transports.RabbitMQQuorumConventionalRouting.Tests` | RabbitMQ | +| `ServiceControl.Transports.RabbitMQQuorumDirectRouting.Tests` | RabbitMQ | +| `ServiceControl.Transports.SqlServer.Tests` | SqlServer | +| `ServiceControl.Transports.SQS.Tests` | SQS | + +## Testing Framework and Conventions + +All projects use: + +- **Framework**: NUnit 3.x +- **Test Adapter**: NUnit3TestAdapter +- **SDK**: Microsoft.NET.Test.Sdk +- **Target Framework**: `net8.0` (Windows-specific tests use `net8.0-windows`) + +### Test Class Structure + +```csharp +[TestFixture] +[FixtureLifeCycle(LifeCycle.InstancePerTestCase)] // Each test gets fresh instance +public class MyTests +{ + [SetUp] + public async Task Setup() + { + // Per-test initialization + } + + [TearDown] + public async Task TearDown() + { + // Per-test cleanup + } + + [Test] + public async Task Should_do_something() + { + // Arrange, Act, Assert + } +} +``` + +### Approval Testing + +Used for API contracts and serialization verification: + +```csharp +[Test] +public void VerifyApiContract() +{ + var result = GetApiContract(); + Approver.Verify(result); +} +``` + +Baseline files stored in `ApprovalFiles/` directories with naming pattern: `{TestName}.{Method}.approved.txt` + +## Transport Filtering System + +Tests can be filtered by transport using the `ServiceControl_TESTS_FILTER` environment variable. + +### Filter Attributes + +Located in `src/TestHelper/IncludeInTestsAttribute.cs`: + +| Attribute | Filter Value | +|-----------|--------------| +| `[IncludeInDefaultTests]` | Default | +| `[IncludeInAzureServiceBusTests]` | AzureServiceBus | +| `[IncludeInAzureStorageQueuesTests]` | AzureStorageQueues | +| `[IncludeInMsmqTests]` | MSMQ | +| `[IncludeInPostgreSqlTests]` | PostgreSql | +| `[IncludeInRabbitMQTests]` | RabbitMQ | +| `[IncludeInSqlServerTests]` | SqlServer | +| `[IncludeInAmazonSqsTests]` | SQS | + +### Usage + +Apply at assembly level to include entire test project: + +```csharp +[assembly: IncludeInDefaultTests()] +``` + +Run filtered tests: + +```powershell +$env:ServiceControl_TESTS_FILTER = "Default" +dotnet test src/ServiceControl.sln +``` + +## Base Classes and Utilities + +### Unit Test Base Classes + +For simple unit tests, no special base class is required. Use standard NUnit patterns. + +### Persistence Test Base Class + +Location: `src/ServiceControl.Persistence.Tests/PersistenceTestBase.cs` + +```csharp +[TestFixture] +[FixtureLifeCycle(LifeCycle.InstancePerTestCase)] +public abstract class PersistenceTestBase +{ + protected PersistenceSettings PersistenceSettings { get; } + protected IErrorMessageDataStore ErrorStore { get; } + protected IRetryDocumentDataStore RetryStore { get; } + protected IBodyStorage BodyStorage { get; } + protected IRetryBatchesDataStore RetryBatchesStore { get; } + protected IMessageRedirectsDataStore MessageRedirectsDataStore { get; } + protected IMonitoringDataStore MonitoringDataStore { get; } + protected ICustomChecksDataStore CustomChecks { get; } + protected IArchiveMessages ArchiveMessages { get; } + protected IEventLogDataStore EventLogDataStore { get; } + protected IServiceProvider ServiceProvider { get; } + + // Async setup/teardown with embedded database management + [SetUp] + public async Task Setup(); + + [TearDown] + public async Task TearDown(); +} +``` + +### RavenDB Persistence Test Base + +Location: `src/ServiceControl.Persistence.Tests.RavenDB/RavenPersistenceTestBase.cs` + +Extends `PersistenceTestBase` with direct RavenDB access: + +```csharp +public abstract class RavenPersistenceTestBase : PersistenceTestBase +{ + protected IDocumentStore DocumentStore { get; } + protected IRavenSessionProvider SessionProvider { get; } + + // Debug helper - blocks test to inspect embedded database + protected void BlockToInspectDatabase(); +} +``` + +### Acceptance Test Base Class + +Location: `src/ServiceControl.AcceptanceTests/TestSupport/AcceptanceTest.cs` + +```csharp +public abstract class AcceptanceTest : NServiceBusAcceptanceTest +{ + protected HttpClient HttpClient { get; } + protected JsonSerializerOptions SerializerOptions { get; } + protected IDomainEvents DomainEvents { get; } + protected Action SetSettings { get; set; } + protected Action CustomConfiguration { get; set; } + protected Action CustomizeHostBuilder { get; set; } + + // Create a test scenario + protected IScenarioWithEndpointBehavior Define() + where T : ScenarioContext, new(); +} +``` + +### Transport Test Base Class + +Location: `src/ServiceControl.Transports.Tests/TransportTestFixture.cs` + +```csharp +public abstract class TransportTestFixture +{ + protected TransportTestsConfiguration Configuration { get; } + + // Setup test transport infrastructure + protected Task ProvisionQueues(params string[] queueNames); + + // Start listening for messages + protected Task StartQueueIngestor(string queueName); + + // Monitor queue depth + protected Task StartQueueLengthProvider(Action callback); + + // Send and receive test messages + protected Task SendAndReceiveMessages(int messageCount); +} +``` + +## Test Infrastructure Components + +### Shared Embedded RavenDB Server + +Location: `src/ServiceControl.Persistence.Tests.RavenDB/SharedEmbeddedServer.cs` + +Provides singleton embedded RavenDB server with: + +- Semaphore-based concurrency control +- Automatic database cleanup +- Dynamic port assignment +- Test database isolation with GUID-based names + +### Port Utility + +Location: `src/TestHelper/PortUtility.cs` + +Finds available ports for test services: + +```csharp +var port = PortUtility.FindAvailablePort(startingPort: 33333); +``` + +### App Settings Fixture + +Location: Various test projects, `AppSettingsFixture.cs` + +One-time assembly setup that loads `app.config` settings into `ConfigurationManager`. + +## Directory Structure Within Test Projects + +Unit test projects are typically organized by domain: + +```text +ServiceControl.UnitTests/ +├── API/ # API controller tests +├── Recoverability/ # Retry and recovery logic +├── Infrastructure/ # Extension and utility tests +├── Monitoring/ # Monitoring component tests +├── Notifications/ # Notification infrastructure +├── Licensing/ # License validation tests +├── BodyStorage/ # Message body storage +├── ExternalIntegrations/ # External system integration +├── ApprovalFiles/ # Approval test baselines +└── ... +``` + +## Adding Tests for New Functionality + +### Decision Tree: Where Should My Test Go? + +```text +Is it testing a single class/method in isolation? +├─ Yes → Unit Tests (ServiceControl.UnitTests, etc.) +│ +├─ No → Does it require persistence? +│ ├─ Yes → Persistence Tests (ServiceControl.Persistence.Tests.*) +│ │ +│ └─ No → Does it require transport infrastructure? +│ ├─ Yes → Transport Tests (ServiceControl.Transports.*.Tests) +│ │ +│ └─ No → Does it require full ServiceControl instance? +│ ├─ Yes → Does it involve multiple instances? +│ │ ├─ Yes → MultiInstance.AcceptanceTests +│ │ └─ No → AcceptanceTests (Primary/Audit/Monitoring) +│ │ +│ └─ No → Unit Tests with mocks +``` + +### Example: Adding Tests for Forward Headers Configuration + +For a feature like forward headers, tests focus on the **settings/parsing logic**. The middleware itself is a thin wrapper around ASP.NET Core's `UseForwardedHeaders()` and doesn't require separate unit testing. + +#### Unit Tests for Configuration Parsing + +Location: `src/ServiceControl.UnitTests/Infrastructure/Settings/ForwardedHeadersSettingsTests.cs` + +```csharp +/// +/// Tests for ForwardedHeadersSettings which is shared infrastructure code +/// used by all three instance types. Testing with one namespace is sufficient. +/// +[TestFixture] +public class ForwardedHeadersSettingsTests +{ + static readonly SettingsRootNamespace TestNamespace = new("ServiceControl"); + + [TearDown] + public void TearDown() + { + // Clean up environment variables after each test + Environment.SetEnvironmentVariable("SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES", null); + } + + [Test] + public void Should_parse_known_proxies_from_comma_separated_list() + { + Environment.SetEnvironmentVariable("SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES", "127.0.0.1,10.0.0.5"); + + var settings = new ForwardedHeadersSettings(TestNamespace); + + Assert.That(settings.KnownProxiesRaw, Has.Count.EqualTo(2)); + } +} +``` + +#### End-to-End Testing + +For middleware configuration like forward headers, end-to-end behavior is best verified through: + +1. **Manual testing** with curl - documented in [local-forward-headers-testing.md](local-forward-headers-testing.md) +2. **Acceptance tests** (optional) - only if automated verification is needed + +The middleware extension (`UseServiceControlForwardedHeaders`) is configuration wiring that delegates to ASP.NET Core's built-in middleware. Unit testing it would require mocking `WebApplication` and would essentially test ASP.NET Core rather than our code. + +### Example: Adding Tests for API Endpoints + +#### Unit Test for Controller Logic + +```csharp +[TestFixture] +public class MyControllerTests +{ + [Test] + public async Task Get_should_return_expected_data() + { + var mockDataStore = new Mock(); + mockDataStore.Setup(x => x.GetData()).ReturnsAsync(expectedData); + + var controller = new MyController(mockDataStore.Object); + var result = await controller.Get(); + + Assert.That(result, Is.EqualTo(expectedData)); + } +} +``` + +#### Acceptance Test for Full API Flow + +```csharp +[TestFixture] +public class When_calling_my_api_endpoint : AcceptanceTest +{ + [Test] + public async Task Should_return_correct_response() + { + await Define() + .WithEndpoint() + .Done(async c => + { + var response = await HttpClient.GetAsync("/api/my-endpoint"); + if (response.IsSuccessStatusCode) + { + c.Response = await response.Content.ReadAsStringAsync(); + return true; + } + return false; + }) + .Run(); + + Assert.That(context.Response, Is.Not.Null); + } + + class MyContext : ScenarioContext + { + public string Response { get; set; } + } +} +``` + +## Test Patterns and Best Practices + +### Async-First Testing + +All test setup, execution, and teardown support async patterns: + +```csharp +[Test] +public async Task Should_handle_async_operation() +{ + var result = await SomeAsyncOperation(); + Assert.That(result, Is.True); +} +``` + +### Instance Per Test Case + +Use `[FixtureLifeCycle(LifeCycle.InstancePerTestCase)]` for test isolation: + +```csharp +[TestFixture] +[FixtureLifeCycle(LifeCycle.InstancePerTestCase)] +public class MyTests +{ + // Each test gets a fresh instance of this class +} +``` + +### Dependency Injection in Tests + +Access services through `IServiceProvider`: + +```csharp +protected IServiceProvider ServiceProvider { get; } + +[Test] +public void Should_resolve_service() +{ + var myService = ServiceProvider.GetRequiredService(); + // Use service +} +``` + +### Using WaitUntil for Async Verification + +```csharp +await WaitUntil(async () => +{ + var result = await CheckCondition(); + return result.IsReady; +}, timeoutInSeconds: 30); +``` + +### Test Timeout Handling + +Transport tests use configured timeouts: + +```csharp +protected TimeSpan TestTimeout => TimeSpan.FromSeconds(60); +``` + +## Running Tests + +### All Tests + +```bash +dotnet test src/ServiceControl.sln +``` + +### By Transport Filter + +```powershell +$env:ServiceControl_TESTS_FILTER = "Default" +dotnet test src/ServiceControl.sln +``` + +### Specific Project + +```bash +dotnet test src/ServiceControl.UnitTests/ServiceControl.UnitTests.csproj +``` + +### Single Test by Name + +```bash +dotnet test src/ServiceControl.UnitTests/ServiceControl.UnitTests.csproj --filter "FullyQualifiedName~MyTestMethodName" +``` + +### With Verbose Output + +```bash +dotnet test src/ServiceControl.sln --logger "console;verbosity=detailed" +``` + +## Environment Variables for Transport Tests + +| Transport | Environment Variable | +|-----------|---------------------| +| SQL Server | `ServiceControl_TransportTests_SQL_ConnectionString` | +| Azure Service Bus | `ServiceControl_TransportTests_ASBS_ConnectionString` | +| Azure Storage Queues | `ServiceControl_TransportTests_ASQ_ConnectionString` | +| RabbitMQ | `ServiceControl_TransportTests_RabbitMQ_ConnectionString` | +| AWS SQS | `ServiceControl_TransportTests_SQS_*` | +| PostgreSQL | `ServiceControl_TransportTests_PostgreSql_ConnectionString` | + +## Summary + +When adding tests for new functionality: + +1. **Start with unit tests** for isolated logic (configuration parsing, algorithms, helpers) +2. **Add persistence tests** if the feature involves data storage +3. **Add acceptance tests** for end-to-end API and behavior verification +4. **Add transport tests** if the feature involves transport-specific behavior +5. Follow existing patterns in similar test files +6. Use appropriate base classes to reduce boilerplate +7. Ensure tests run with the Default filter for CI compatibility diff --git a/src/ServiceControl.AcceptanceTesting/ForwardedHeaders/ForwardedHeadersAssertions.cs b/src/ServiceControl.AcceptanceTesting/ForwardedHeaders/ForwardedHeadersAssertions.cs new file mode 100644 index 0000000000..e18b272d2e --- /dev/null +++ b/src/ServiceControl.AcceptanceTesting/ForwardedHeaders/ForwardedHeadersAssertions.cs @@ -0,0 +1,271 @@ +namespace ServiceControl.AcceptanceTesting.ForwardedHeaders +{ + using System.Net.Http; + using System.Text.Json; + using System.Threading.Tasks; + using NUnit.Framework; + + /// + /// Shared assertion helpers for forwarded headers acceptance tests. + /// Used across all instance types (Primary, Audit, Monitoring). + /// + public static class ForwardedHeadersAssertions + { + /// + /// Fetches request info from the debug endpoint with optional custom forwarded headers. + /// + /// The HTTP client to use + /// JSON serializer options + /// X-Forwarded-For header value + /// X-Forwarded-Proto header value + /// X-Forwarded-Host header value + /// Test-only: Simulates the request coming from this IP address. + /// Used to test ForwardedHeaders behavior with KnownProxies/KnownNetworks configurations. + public static async Task GetRequestInfo( + HttpClient httpClient, + JsonSerializerOptions serializerOptions, + string xForwardedFor = null, + string xForwardedProto = null, + string xForwardedHost = null, + string testRemoteIp = null) + { + using var request = new HttpRequestMessage(HttpMethod.Get, "/debug/request-info"); + + if (!string.IsNullOrEmpty(xForwardedFor)) + { + request.Headers.Add("X-Forwarded-For", xForwardedFor); + } + if (!string.IsNullOrEmpty(xForwardedProto)) + { + request.Headers.Add("X-Forwarded-Proto", xForwardedProto); + } + if (!string.IsNullOrEmpty(xForwardedHost)) + { + request.Headers.Add("X-Forwarded-Host", xForwardedHost); + } + if (!string.IsNullOrEmpty(testRemoteIp)) + { + request.Headers.Add("X-Test-Remote-IP", testRemoteIp); + } + + var response = await httpClient.SendAsync(request); + response.EnsureSuccessStatusCode(); + + var content = await response.Content.ReadAsStringAsync(); + return JsonSerializer.Deserialize(content, serializerOptions); + } + + /// + /// Asserts Scenario 0: Direct Access (No Proxy) + /// When no forwarded headers are sent, the request values should remain unchanged. + /// + public static void AssertDirectAccessWithNoForwardedHeaders(RequestInfoResponse requestInfo) + { + Assert.That(requestInfo, Is.Not.Null); + + // Processed values should reflect the direct request (no proxy transformation) + Assert.That(requestInfo.Processed.Scheme, Is.EqualTo("http")); + Assert.That(requestInfo.Processed.Host, Is.Not.Null.And.Not.Empty); + + // Raw headers should be empty since no forwarded headers were sent + Assert.That(requestInfo.RawHeaders.XForwardedFor, Is.Empty); + Assert.That(requestInfo.RawHeaders.XForwardedProto, Is.Empty); + Assert.That(requestInfo.RawHeaders.XForwardedHost, Is.Empty); + + // Default configuration: enabled with trust all proxies + Assert.That(requestInfo.Configuration.Enabled, Is.True); + Assert.That(requestInfo.Configuration.TrustAllProxies, Is.True); + } + + /// + /// Asserts Scenario 1/2: Default Behavior with Headers (TrustAllProxies = true) + /// When forwarded headers are sent and all proxies are trusted, headers should be applied. + /// + public static void AssertHeadersAppliedWhenTrustAllProxies( + RequestInfoResponse requestInfo, + string expectedScheme, + string expectedHost, + string expectedRemoteIp) + { + Assert.That(requestInfo, Is.Not.Null); + + // Processed values should reflect the forwarded headers + Assert.That(requestInfo.Processed.Scheme, Is.EqualTo(expectedScheme)); + Assert.That(requestInfo.Processed.Host, Is.EqualTo(expectedHost)); + Assert.That(requestInfo.Processed.RemoteIpAddress, Is.EqualTo(expectedRemoteIp)); + + // Raw headers should be empty because middleware consumed them + Assert.That(requestInfo.RawHeaders.XForwardedFor, Is.Empty); + Assert.That(requestInfo.RawHeaders.XForwardedProto, Is.Empty); + Assert.That(requestInfo.RawHeaders.XForwardedHost, Is.Empty); + + // Configuration should show trust all proxies + Assert.That(requestInfo.Configuration.Enabled, Is.True); + Assert.That(requestInfo.Configuration.TrustAllProxies, Is.True); + } + + /// + /// Asserts Scenario 11: Partial Headers (Proto Only) + /// When only X-Forwarded-Proto is sent, only scheme should change. + /// + public static void AssertPartialHeadersApplied( + RequestInfoResponse requestInfo, + string expectedScheme) + { + Assert.That(requestInfo, Is.Not.Null); + + // Only scheme should be changed + Assert.That(requestInfo.Processed.Scheme, Is.EqualTo(expectedScheme)); + + // Host should remain original (not changed to a forwarded value) + // In test environment this will be the test server host, not a forwarded host like "example.com" + Assert.That(requestInfo.Processed.Host, Is.Not.Null.And.Not.Empty); + Assert.That(requestInfo.Processed.Host, Does.Not.Contain("example.com")); + + // RemoteIpAddress should NOT be a forwarded IP (203.0.113.50) + // In test server environment it may be null, localhost, or machine-specific + Assert.That(requestInfo.Processed.RemoteIpAddress, Is.Null.Or.Not.EqualTo("203.0.113.50")); + + // Configuration should show trust all proxies + Assert.That(requestInfo.Configuration.Enabled, Is.True); + Assert.That(requestInfo.Configuration.TrustAllProxies, Is.True); + } + + /// + /// Asserts Scenario 8: Proxy Chain (Multiple X-Forwarded-For Values) + /// When TrustAllProxies is true and multiple IPs are in X-Forwarded-For, + /// the original client IP (first in the chain) should be returned. + /// + public static void AssertProxyChainProcessedWithTrustAllProxies( + RequestInfoResponse requestInfo, + string expectedOriginalClientIp, + string expectedScheme, + string expectedHost) + { + Assert.That(requestInfo, Is.Not.Null); + + // When TrustAllProxies=true, ForwardLimit=null, so middleware processes all IPs + // and returns the original client IP (first in the chain) + Assert.That(requestInfo.Processed.Scheme, Is.EqualTo(expectedScheme)); + Assert.That(requestInfo.Processed.Host, Is.EqualTo(expectedHost)); + Assert.That(requestInfo.Processed.RemoteIpAddress, Is.EqualTo(expectedOriginalClientIp)); + + // Raw headers should be empty because middleware consumed them + Assert.That(requestInfo.RawHeaders.XForwardedFor, Is.Empty); + Assert.That(requestInfo.RawHeaders.XForwardedProto, Is.Empty); + Assert.That(requestInfo.RawHeaders.XForwardedHost, Is.Empty); + + // Configuration should show trust all proxies + Assert.That(requestInfo.Configuration.Enabled, Is.True); + Assert.That(requestInfo.Configuration.TrustAllProxies, Is.True); + } + + /// + /// Asserts Scenario 3/4/10: Headers Applied with Known Proxies/Networks + /// When the caller IP matches KnownProxies or KnownNetworks, headers should be applied. + /// + public static void AssertHeadersAppliedWithKnownProxiesOrNetworks( + RequestInfoResponse requestInfo, + string expectedScheme, + string expectedHost, + string expectedRemoteIp) + { + Assert.That(requestInfo, Is.Not.Null); + + // Headers should be applied because caller is trusted + Assert.That(requestInfo.Processed.Scheme, Is.EqualTo(expectedScheme)); + Assert.That(requestInfo.Processed.Host, Is.EqualTo(expectedHost)); + Assert.That(requestInfo.Processed.RemoteIpAddress, Is.EqualTo(expectedRemoteIp)); + + // Raw headers should be empty because middleware consumed them + Assert.That(requestInfo.RawHeaders.XForwardedFor, Is.Empty); + Assert.That(requestInfo.RawHeaders.XForwardedProto, Is.Empty); + Assert.That(requestInfo.RawHeaders.XForwardedHost, Is.Empty); + + // Configuration should show TrustAllProxies=false (auto-disabled when proxies/networks configured) + Assert.That(requestInfo.Configuration.Enabled, Is.True); + Assert.That(requestInfo.Configuration.TrustAllProxies, Is.False); + } + + /// + /// Asserts Scenario 5/6: Headers Ignored when Proxy/Network Not Trusted + /// When the caller IP does NOT match KnownProxies or KnownNetworks, headers should be ignored. + /// + public static void AssertHeadersIgnoredWhenProxyNotTrusted( + RequestInfoResponse requestInfo, + string sentXForwardedFor, + string sentXForwardedProto, + string sentXForwardedHost) + { + Assert.That(requestInfo, Is.Not.Null); + + // Headers should NOT be applied - values should remain unchanged from direct request + Assert.That(requestInfo.Processed.Scheme, Is.EqualTo("http")); + // Host should remain the test server host, not the forwarded host + Assert.That(requestInfo.Processed.Host, Does.Not.Contain("example.com")); + + // Raw headers should still contain the sent values (not consumed by middleware) + Assert.That(requestInfo.RawHeaders.XForwardedFor, Is.EqualTo(sentXForwardedFor)); + Assert.That(requestInfo.RawHeaders.XForwardedProto, Is.EqualTo(sentXForwardedProto)); + Assert.That(requestInfo.RawHeaders.XForwardedHost, Is.EqualTo(sentXForwardedHost)); + + // Configuration should show TrustAllProxies=false + Assert.That(requestInfo.Configuration.Enabled, Is.True); + Assert.That(requestInfo.Configuration.TrustAllProxies, Is.False); + } + + /// + /// Asserts Scenario 7: Forwarded Headers Disabled + /// When forwarded headers processing is disabled, headers should be ignored regardless of trust. + /// + public static void AssertHeadersIgnoredWhenDisabled( + RequestInfoResponse requestInfo, + string sentXForwardedFor, + string sentXForwardedProto, + string sentXForwardedHost) + { + Assert.That(requestInfo, Is.Not.Null); + + // Headers should NOT be applied - values should remain unchanged + Assert.That(requestInfo.Processed.Scheme, Is.EqualTo("http")); + Assert.That(requestInfo.Processed.Host, Does.Not.Contain("example.com")); + + // Raw headers should still contain the sent values (middleware disabled) + Assert.That(requestInfo.RawHeaders.XForwardedFor, Is.EqualTo(sentXForwardedFor)); + Assert.That(requestInfo.RawHeaders.XForwardedProto, Is.EqualTo(sentXForwardedProto)); + Assert.That(requestInfo.RawHeaders.XForwardedHost, Is.EqualTo(sentXForwardedHost)); + + // Configuration should show Enabled=false + Assert.That(requestInfo.Configuration.Enabled, Is.False); + } + + /// + /// Asserts Scenario 9: Proxy Chain with ForwardLimit=1 (Known Proxies) + /// When TrustAllProxies=false, ForwardLimit=1, so only the last proxy IP is processed. + /// + public static void AssertProxyChainWithForwardLimitOne( + RequestInfoResponse requestInfo, + string expectedLastProxyIp, + string expectedScheme, + string expectedHost, + string expectedRemainingForwardedFor) + { + Assert.That(requestInfo, Is.Not.Null); + + // When TrustAllProxies=false, ForwardLimit=1, so only last IP is processed + Assert.That(requestInfo.Processed.Scheme, Is.EqualTo(expectedScheme)); + Assert.That(requestInfo.Processed.Host, Is.EqualTo(expectedHost)); + Assert.That(requestInfo.Processed.RemoteIpAddress, Is.EqualTo(expectedLastProxyIp)); + + // X-Forwarded-For should contain remaining IPs (not fully consumed) + Assert.That(requestInfo.RawHeaders.XForwardedFor, Is.EqualTo(expectedRemainingForwardedFor)); + // Proto and Host are fully consumed + Assert.That(requestInfo.RawHeaders.XForwardedProto, Is.Empty); + Assert.That(requestInfo.RawHeaders.XForwardedHost, Is.Empty); + + // Configuration should show TrustAllProxies=false + Assert.That(requestInfo.Configuration.Enabled, Is.True); + Assert.That(requestInfo.Configuration.TrustAllProxies, Is.False); + } + } +} diff --git a/src/ServiceControl.AcceptanceTesting/ForwardedHeaders/ForwardedHeadersTestConfiguration.cs b/src/ServiceControl.AcceptanceTesting/ForwardedHeaders/ForwardedHeadersTestConfiguration.cs new file mode 100644 index 0000000000..0b087e47ab --- /dev/null +++ b/src/ServiceControl.AcceptanceTesting/ForwardedHeaders/ForwardedHeadersTestConfiguration.cs @@ -0,0 +1,136 @@ +namespace ServiceControl.AcceptanceTesting.ForwardedHeaders +{ + using System; + + /// + /// Helper class to configure ForwardedHeaders environment variables for acceptance tests. + /// Environment variables must be set before the ServiceControl instance starts. + /// + public class ForwardedHeadersTestConfiguration : IDisposable + { + readonly string envVarPrefix; + bool disposed; + + /// + /// Creates a new forwarded headers test configuration. + /// + /// The instance type (determines environment variable prefix) + public ForwardedHeadersTestConfiguration(ServiceControlInstanceType instanceType) + { + envVarPrefix = instanceType switch + { + ServiceControlInstanceType.Primary => "SERVICECONTROL_", + ServiceControlInstanceType.Audit => "SERVICECONTROL_AUDIT_", + ServiceControlInstanceType.Monitoring => "MONITORING_", + _ => throw new ArgumentOutOfRangeException(nameof(instanceType)) + }; + } + + /// + /// Configures forwarded headers to be disabled. + /// + public ForwardedHeadersTestConfiguration WithForwardedHeadersDisabled() + { + SetEnvironmentVariable("FORWARDEDHEADERS_ENABLED", "false"); + return this; + } + + /// + /// Configures forwarded headers to trust all proxies (default behavior). + /// + public ForwardedHeadersTestConfiguration WithTrustAllProxies() + { + SetEnvironmentVariable("FORWARDEDHEADERS_ENABLED", "true"); + SetEnvironmentVariable("FORWARDEDHEADERS_TRUSTALLPROXIES", "true"); + return this; + } + + /// + /// Configures forwarded headers with specific known proxies. + /// Setting known proxies automatically disables TrustAllProxies. + /// + /// Comma-separated list of trusted proxy IP addresses (e.g., "127.0.0.1,::1") + public ForwardedHeadersTestConfiguration WithKnownProxies(string proxies) + { + SetEnvironmentVariable("FORWARDEDHEADERS_ENABLED", "true"); + SetEnvironmentVariable("FORWARDEDHEADERS_KNOWNPROXIES", proxies); + return this; + } + + /// + /// Configures forwarded headers with specific known networks. + /// Setting known networks automatically disables TrustAllProxies. + /// + /// Comma-separated list of trusted CIDR networks (e.g., "127.0.0.0/8,::1/128") + public ForwardedHeadersTestConfiguration WithKnownNetworks(string networks) + { + SetEnvironmentVariable("FORWARDEDHEADERS_ENABLED", "true"); + SetEnvironmentVariable("FORWARDEDHEADERS_KNOWNNETWORKS", networks); + return this; + } + + /// + /// Configures forwarded headers with both known proxies and networks. + /// + /// Comma-separated list of trusted proxy IP addresses + /// Comma-separated list of trusted CIDR networks + public ForwardedHeadersTestConfiguration WithKnownProxiesAndNetworks(string proxies, string networks) + { + SetEnvironmentVariable("FORWARDEDHEADERS_ENABLED", "true"); + SetEnvironmentVariable("FORWARDEDHEADERS_KNOWNPROXIES", proxies); + SetEnvironmentVariable("FORWARDEDHEADERS_KNOWNNETWORKS", networks); + return this; + } + + /// + /// Applies the configuration by ensuring environment variables are set. + /// This should be called before the ServiceControl instance starts. + /// + public void Apply() + { + // Configuration is already applied via the With* methods + // This method exists for explicit apply semantics if needed + } + + /// + /// Clears all forwarded headers environment variables. + /// Called automatically on Dispose. + /// + public void ClearConfiguration() + { + ClearEnvironmentVariable("FORWARDEDHEADERS_ENABLED"); + ClearEnvironmentVariable("FORWARDEDHEADERS_TRUSTALLPROXIES"); + ClearEnvironmentVariable("FORWARDEDHEADERS_KNOWNPROXIES"); + ClearEnvironmentVariable("FORWARDEDHEADERS_KNOWNNETWORKS"); + } + + void SetEnvironmentVariable(string name, string value) + { + Environment.SetEnvironmentVariable(envVarPrefix + name, value); + } + + void ClearEnvironmentVariable(string name) + { + Environment.SetEnvironmentVariable(envVarPrefix + name, null); + } + + public void Dispose() + { + if (!disposed) + { + ClearConfiguration(); + disposed = true; + } + } + } + + /// + /// Identifies the ServiceControl instance type for environment variable prefix selection. + /// + public enum ServiceControlInstanceType + { + Primary, + Audit, + Monitoring + } +} diff --git a/src/ServiceControl.AcceptanceTesting/ForwardedHeaders/RequestInfoResponse.cs b/src/ServiceControl.AcceptanceTesting/ForwardedHeaders/RequestInfoResponse.cs new file mode 100644 index 0000000000..e8c8148536 --- /dev/null +++ b/src/ServiceControl.AcceptanceTesting/ForwardedHeaders/RequestInfoResponse.cs @@ -0,0 +1,60 @@ +namespace ServiceControl.AcceptanceTesting.ForwardedHeaders +{ + using System.Text.Json.Serialization; + + /// + /// Response DTO for the /debug/request-info endpoint. + /// Used by forwarded headers acceptance tests to verify request processing. + /// Shared across all instance types (Primary, Audit, Monitoring). + /// + public class RequestInfoResponse + { + [JsonPropertyName("processed")] + public ProcessedInfo Processed { get; set; } + + [JsonPropertyName("rawHeaders")] + public RawHeadersInfo RawHeaders { get; set; } + + [JsonPropertyName("configuration")] + public ConfigurationInfo Configuration { get; set; } + } + + public class ProcessedInfo + { + [JsonPropertyName("scheme")] + public string Scheme { get; set; } + + [JsonPropertyName("host")] + public string Host { get; set; } + + [JsonPropertyName("remoteIpAddress")] + public string RemoteIpAddress { get; set; } + } + + public class RawHeadersInfo + { + [JsonPropertyName("xForwardedFor")] + public string XForwardedFor { get; set; } + + [JsonPropertyName("xForwardedProto")] + public string XForwardedProto { get; set; } + + [JsonPropertyName("xForwardedHost")] + public string XForwardedHost { get; set; } + } + + public class ConfigurationInfo + { + [JsonPropertyName("enabled")] + public bool Enabled { get; set; } + + [JsonPropertyName("trustAllProxies")] + public bool TrustAllProxies { get; set; } + + [JsonPropertyName("knownProxies")] + public string[] KnownProxies { get; set; } + + [JsonPropertyName("knownNetworks")] + public string[] KnownNetworks { get; set; } + } +} diff --git a/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_combined_proxies_and_networks_are_configured.cs b/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_combined_proxies_and_networks_are_configured.cs new file mode 100644 index 0000000000..ad25d04784 --- /dev/null +++ b/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_combined_proxies_and_networks_are_configured.cs @@ -0,0 +1,65 @@ +namespace ServiceControl.AcceptanceTests.Security.ForwardedHeaders +{ + using System.Threading.Tasks; + using AcceptanceTesting; + using AcceptanceTesting.ForwardedHeaders; + using NServiceBus.AcceptanceTesting; + using NUnit.Framework; + + /// + /// Tests Scenario 10: Combined Known Proxies and Networks from local-forward-headers-testing.md + /// When both KnownProxies and KnownNetworks are configured, matching either grants trust. + /// + class When_combined_proxies_and_networks_are_configured : AcceptanceTest + { + ForwardedHeadersTestConfiguration configuration; + + [SetUp] + public void ConfigureForwardedHeaders() + { + // Configure both proxies (that don't match localhost) and networks (that include localhost) + // The localhost should match via the networks, proving OR logic + configuration = new ForwardedHeadersTestConfiguration(ServiceControlInstanceType.Primary) + .WithKnownProxiesAndNetworks("192.168.1.100", "127.0.0.0/8,::1/128"); + } + + [TearDown] + public void CleanupForwardedHeaders() + { + configuration?.Dispose(); + } + + [Test] + public async Task Headers_should_be_applied_when_caller_matches_network_but_not_proxy() + { + RequestInfoResponse requestInfo = null; + + await Define() + .Done(async ctx => + { + requestInfo = await ForwardedHeadersAssertions.GetRequestInfo( + HttpClient, + SerializerOptions, + xForwardedFor: "203.0.113.50", + xForwardedProto: "https", + xForwardedHost: "example.com"); + return requestInfo != null; + }) + .Run(); + + ForwardedHeadersAssertions.AssertHeadersAppliedWithKnownProxiesOrNetworks( + requestInfo, + expectedScheme: "https", + expectedHost: "example.com", + expectedRemoteIp: "203.0.113.50"); + + // Verify configuration shows both proxies and networks + Assert.That(requestInfo.Configuration.KnownProxies, Does.Contain("192.168.1.100")); + Assert.That(requestInfo.Configuration.KnownNetworks, Does.Contain("127.0.0.0/8").Or.Contain("::1/128")); + } + + class Context : ScenarioContext + { + } + } +} diff --git a/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_forwarded_headers_are_disabled.cs b/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_forwarded_headers_are_disabled.cs new file mode 100644 index 0000000000..b7573e3dcc --- /dev/null +++ b/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_forwarded_headers_are_disabled.cs @@ -0,0 +1,60 @@ +namespace ServiceControl.AcceptanceTests.Security.ForwardedHeaders +{ + using System.Threading.Tasks; + using AcceptanceTesting; + using AcceptanceTesting.ForwardedHeaders; + using NServiceBus.AcceptanceTesting; + using NUnit.Framework; + + /// + /// Tests Scenario 7: Forwarded Headers Disabled from local-forward-headers-testing.md + /// When forwarded headers processing is disabled, headers should be ignored regardless of trust. + /// + class When_forwarded_headers_are_disabled : AcceptanceTest + { + ForwardedHeadersTestConfiguration configuration; + + [SetUp] + public void ConfigureForwardedHeaders() + { + // Disable forwarded headers processing entirely + configuration = new ForwardedHeadersTestConfiguration(ServiceControlInstanceType.Primary) + .WithForwardedHeadersDisabled(); + } + + [TearDown] + public void CleanupForwardedHeaders() + { + configuration?.Dispose(); + } + + [Test] + public async Task Headers_should_be_ignored_when_disabled() + { + RequestInfoResponse requestInfo = null; + + await Define() + .Done(async ctx => + { + requestInfo = await ForwardedHeadersAssertions.GetRequestInfo( + HttpClient, + SerializerOptions, + xForwardedFor: "203.0.113.50", + xForwardedProto: "https", + xForwardedHost: "example.com"); + return requestInfo != null; + }) + .Run(); + + ForwardedHeadersAssertions.AssertHeadersIgnoredWhenDisabled( + requestInfo, + sentXForwardedFor: "203.0.113.50", + sentXForwardedProto: "https", + sentXForwardedHost: "example.com"); + } + + class Context : ScenarioContext + { + } + } +} diff --git a/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_forwarded_headers_are_sent.cs b/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_forwarded_headers_are_sent.cs new file mode 100644 index 0000000000..ad2b795f67 --- /dev/null +++ b/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_forwarded_headers_are_sent.cs @@ -0,0 +1,44 @@ +namespace ServiceControl.AcceptanceTests.Security.ForwardedHeaders +{ + using System.Threading.Tasks; + using AcceptanceTesting; + using AcceptanceTesting.ForwardedHeaders; + using NServiceBus.AcceptanceTesting; + using NUnit.Framework; + + /// + /// Tests Scenario 1/2: Default Behavior with Headers from local-forward-headers-testing.md + /// When forwarded headers are sent and TrustAllProxies is true (default), headers should be applied. + /// + class When_forwarded_headers_are_sent : AcceptanceTest + { + [Test] + public async Task Headers_should_be_applied_when_trust_all_proxies() + { + RequestInfoResponse requestInfo = null; + + await Define() + .Done(async ctx => + { + requestInfo = await ForwardedHeadersAssertions.GetRequestInfo( + HttpClient, + SerializerOptions, + xForwardedFor: "203.0.113.50", + xForwardedProto: "https", + xForwardedHost: "example.com"); + return requestInfo != null; + }) + .Run(); + + ForwardedHeadersAssertions.AssertHeadersAppliedWhenTrustAllProxies( + requestInfo, + expectedScheme: "https", + expectedHost: "example.com", + expectedRemoteIp: "203.0.113.50"); + } + + class Context : ScenarioContext + { + } + } +} \ No newline at end of file diff --git a/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_known_networks_are_configured.cs b/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_known_networks_are_configured.cs new file mode 100644 index 0000000000..7ffdae9756 --- /dev/null +++ b/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_known_networks_are_configured.cs @@ -0,0 +1,63 @@ +namespace ServiceControl.AcceptanceTests.Security.ForwardedHeaders +{ + using System.Threading.Tasks; + using AcceptanceTesting; + using AcceptanceTesting.ForwardedHeaders; + using NServiceBus.AcceptanceTesting; + using NUnit.Framework; + + /// + /// Tests Scenario 4: Known Networks (CIDR) from local-forward-headers-testing.md + /// When KnownNetworks are configured and the caller IP falls within, headers should be applied. + /// + class When_known_networks_are_configured : AcceptanceTest + { + ForwardedHeadersTestConfiguration configuration; + + [SetUp] + public void ConfigureForwardedHeaders() + { + // Configure known networks to include localhost CIDR ranges (test server uses localhost) + configuration = new ForwardedHeadersTestConfiguration(ServiceControlInstanceType.Primary) + .WithKnownNetworks("127.0.0.0/8,::1/128"); + } + + [TearDown] + public void CleanupForwardedHeaders() + { + configuration?.Dispose(); + } + + [Test] + public async Task Headers_should_be_applied_when_caller_matches_known_network() + { + RequestInfoResponse requestInfo = null; + + await Define() + .Done(async ctx => + { + requestInfo = await ForwardedHeadersAssertions.GetRequestInfo( + HttpClient, + SerializerOptions, + xForwardedFor: "203.0.113.50", + xForwardedProto: "https", + xForwardedHost: "example.com"); + return requestInfo != null; + }) + .Run(); + + ForwardedHeadersAssertions.AssertHeadersAppliedWithKnownProxiesOrNetworks( + requestInfo, + expectedScheme: "https", + expectedHost: "example.com", + expectedRemoteIp: "203.0.113.50"); + + // Verify configuration shows known networks + Assert.That(requestInfo.Configuration.KnownNetworks, Does.Contain("127.0.0.0/8").Or.Contain("::1/128")); + } + + class Context : ScenarioContext + { + } + } +} diff --git a/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_known_proxies_are_configured.cs b/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_known_proxies_are_configured.cs new file mode 100644 index 0000000000..fd026c3810 --- /dev/null +++ b/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_known_proxies_are_configured.cs @@ -0,0 +1,63 @@ +namespace ServiceControl.AcceptanceTests.Security.ForwardedHeaders +{ + using System.Threading.Tasks; + using AcceptanceTesting; + using AcceptanceTesting.ForwardedHeaders; + using NServiceBus.AcceptanceTesting; + using NUnit.Framework; + + /// + /// Tests Scenario 3: Known Proxies Only from local-forward-headers-testing.md + /// When KnownProxies are configured and the caller IP matches, headers should be applied. + /// + class When_known_proxies_are_configured : AcceptanceTest + { + ForwardedHeadersTestConfiguration configuration; + + [SetUp] + public void ConfigureForwardedHeaders() + { + // Configure known proxies to include localhost addresses (test server uses localhost) + configuration = new ForwardedHeadersTestConfiguration(ServiceControlInstanceType.Primary) + .WithKnownProxies("127.0.0.1,::1"); + } + + [TearDown] + public void CleanupForwardedHeaders() + { + configuration?.Dispose(); + } + + [Test] + public async Task Headers_should_be_applied_when_caller_matches_known_proxy() + { + RequestInfoResponse requestInfo = null; + + await Define() + .Done(async ctx => + { + requestInfo = await ForwardedHeadersAssertions.GetRequestInfo( + HttpClient, + SerializerOptions, + xForwardedFor: "203.0.113.50", + xForwardedProto: "https", + xForwardedHost: "example.com"); + return requestInfo != null; + }) + .Run(); + + ForwardedHeadersAssertions.AssertHeadersAppliedWithKnownProxiesOrNetworks( + requestInfo, + expectedScheme: "https", + expectedHost: "example.com", + expectedRemoteIp: "203.0.113.50"); + + // Verify configuration shows known proxies + Assert.That(requestInfo.Configuration.KnownProxies, Does.Contain("127.0.0.1").Or.Contain("::1")); + } + + class Context : ScenarioContext + { + } + } +} diff --git a/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_only_proto_header_is_sent.cs b/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_only_proto_header_is_sent.cs new file mode 100644 index 0000000000..7fd110d497 --- /dev/null +++ b/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_only_proto_header_is_sent.cs @@ -0,0 +1,38 @@ +namespace ServiceControl.AcceptanceTests.Security.ForwardedHeaders +{ + using System.Threading.Tasks; + using AcceptanceTesting; + using AcceptanceTesting.ForwardedHeaders; + using NServiceBus.AcceptanceTesting; + using NUnit.Framework; + + /// + /// Tests Scenario 11: Partial Headers (Proto Only) from local-forward-headers-testing.md + /// When only X-Forwarded-Proto is sent, only scheme should change. + /// + class When_only_proto_header_is_sent : AcceptanceTest + { + [Test] + public async Task Only_scheme_should_be_changed() + { + RequestInfoResponse requestInfo = null; + + await Define() + .Done(async ctx => + { + requestInfo = await ForwardedHeadersAssertions.GetRequestInfo( + HttpClient, + SerializerOptions, + xForwardedProto: "https"); + return requestInfo != null; + }) + .Run(); + + ForwardedHeadersAssertions.AssertPartialHeadersApplied(requestInfo, expectedScheme: "https"); + } + + class Context : ScenarioContext + { + } + } +} diff --git a/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_proxy_chain_headers_are_sent.cs b/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_proxy_chain_headers_are_sent.cs new file mode 100644 index 0000000000..124de74a5c --- /dev/null +++ b/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_proxy_chain_headers_are_sent.cs @@ -0,0 +1,48 @@ +namespace ServiceControl.AcceptanceTests.Security.ForwardedHeaders +{ + using System.Threading.Tasks; + using AcceptanceTesting; + using AcceptanceTesting.ForwardedHeaders; + using NServiceBus.AcceptanceTesting; + using NUnit.Framework; + + /// + /// Tests Scenario 8: Proxy Chain (Multiple X-Forwarded-For Values) from local-forward-headers-testing.md + /// When TrustAllProxies is true and X-Forwarded-For contains multiple IPs (proxy chain), + /// the original client IP (first in the chain) should be returned. + /// + class When_proxy_chain_headers_are_sent : AcceptanceTest + { + [Test] + public async Task Original_client_ip_should_be_returned_when_trust_all_proxies() + { + RequestInfoResponse requestInfo = null; + + await Define() + .Done(async ctx => + { + // Simulate a proxy chain: client -> proxy1 -> proxy2 -> ServiceControl + // X-Forwarded-For: 203.0.113.50, 10.0.0.1, 192.168.1.1 + // Expected: 203.0.113.50 (original client) + requestInfo = await ForwardedHeadersAssertions.GetRequestInfo( + HttpClient, + SerializerOptions, + xForwardedFor: "203.0.113.50, 10.0.0.1, 192.168.1.1", + xForwardedProto: "https", + xForwardedHost: "example.com"); + return requestInfo != null; + }) + .Run(); + + ForwardedHeadersAssertions.AssertProxyChainProcessedWithTrustAllProxies( + requestInfo, + expectedOriginalClientIp: "203.0.113.50", + expectedScheme: "https", + expectedHost: "example.com"); + } + + class Context : ScenarioContext + { + } + } +} diff --git a/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_proxy_chain_headers_are_sent_with_known_proxies.cs b/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_proxy_chain_headers_are_sent_with_known_proxies.cs new file mode 100644 index 0000000000..f3ba0c46a7 --- /dev/null +++ b/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_proxy_chain_headers_are_sent_with_known_proxies.cs @@ -0,0 +1,64 @@ +namespace ServiceControl.AcceptanceTests.Security.ForwardedHeaders +{ + using System.Threading.Tasks; + using AcceptanceTesting; + using AcceptanceTesting.ForwardedHeaders; + using NServiceBus.AcceptanceTesting; + using NUnit.Framework; + + /// + /// Tests Scenario 9: Proxy Chain with Known Proxies (ForwardLimit=1) from local-forward-headers-testing.md + /// When TrustAllProxies=false (known proxies configured), ForwardLimit=1, so only the last proxy IP is processed. + /// + class When_proxy_chain_headers_are_sent_with_known_proxies : AcceptanceTest + { + ForwardedHeadersTestConfiguration configuration; + + [SetUp] + public void ConfigureForwardedHeaders() + { + // Configure known proxies to include localhost (test server uses localhost) + configuration = new ForwardedHeadersTestConfiguration(ServiceControlInstanceType.Primary) + .WithKnownProxies("127.0.0.1,::1"); + } + + [TearDown] + public void CleanupForwardedHeaders() + { + configuration?.Dispose(); + } + + [Test] + public async Task Only_last_proxy_ip_should_be_processed_when_forward_limit_is_one() + { + RequestInfoResponse requestInfo = null; + + await Define() + .Done(async ctx => + { + // Simulate a proxy chain: client -> proxy1 -> proxy2 -> ServiceControl + // X-Forwarded-For: 203.0.113.50, 10.0.0.1, 192.168.1.1 + // Expected with ForwardLimit=1: 192.168.1.1 (last proxy in chain) + requestInfo = await ForwardedHeadersAssertions.GetRequestInfo( + HttpClient, + SerializerOptions, + xForwardedFor: "203.0.113.50, 10.0.0.1, 192.168.1.1", + xForwardedProto: "https", + xForwardedHost: "example.com"); + return requestInfo != null; + }) + .Run(); + + ForwardedHeadersAssertions.AssertProxyChainWithForwardLimitOne( + requestInfo, + expectedLastProxyIp: "192.168.1.1", + expectedScheme: "https", + expectedHost: "example.com", + expectedRemainingForwardedFor: "203.0.113.50,10.0.0.1"); + } + + class Context : ScenarioContext + { + } + } +} diff --git a/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_request_has_no_forwarded_headers.cs b/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_request_has_no_forwarded_headers.cs new file mode 100644 index 0000000000..a8f38bc405 --- /dev/null +++ b/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_request_has_no_forwarded_headers.cs @@ -0,0 +1,40 @@ +namespace ServiceControl.AcceptanceTests.Security.ForwardedHeaders +{ + using System.Threading.Tasks; + using AcceptanceTesting; + using AcceptanceTesting.ForwardedHeaders; + using NServiceBus.AcceptanceTesting; + using NUnit.Framework; + + /// + /// Tests Scenario 0: Direct Access (No Proxy) from local-forward-headers-testing.md + /// When no forwarded headers are sent, the request values should remain unchanged. + /// + class When_request_has_no_forwarded_headers : AcceptanceTest + { + [Test] + public async Task Request_values_should_remain_unchanged() + { + RequestInfoResponse requestInfo = null; + + await Define() + .Done(async ctx => + { + var result = await this.TryGet("/debug/request-info"); + if (result.HasResult) + { + requestInfo = result.Item; + return true; + } + return false; + }) + .Run(); + + ForwardedHeadersAssertions.AssertDirectAccessWithNoForwardedHeaders(requestInfo); + } + + class Context : ScenarioContext + { + } + } +} diff --git a/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_unknown_network_sends_headers.cs b/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_unknown_network_sends_headers.cs new file mode 100644 index 0000000000..516fd99e24 --- /dev/null +++ b/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_unknown_network_sends_headers.cs @@ -0,0 +1,68 @@ +namespace ServiceControl.AcceptanceTests.Security.ForwardedHeaders +{ + using System.Threading.Tasks; + using AcceptanceTesting; + using AcceptanceTesting.ForwardedHeaders; + using NServiceBus.AcceptanceTesting; + using NUnit.Framework; + + /// + /// Tests Scenario 6: Unknown Network Rejected from local-forward-headers-testing.md + /// When KnownNetworks are configured but the caller IP does NOT fall within, headers should be ignored. + /// + class When_unknown_network_sends_headers : AcceptanceTest + { + ForwardedHeadersTestConfiguration configuration; + + [SetUp] + public void ConfigureForwardedHeaders() + { + // Configure known networks that do NOT include localhost (test server uses localhost) + // This should cause headers to be ignored + configuration = new ForwardedHeadersTestConfiguration(ServiceControlInstanceType.Primary) + .WithKnownNetworks("10.0.0.0/8,192.168.0.0/16"); + } + + [TearDown] + public void CleanupForwardedHeaders() + { + configuration?.Dispose(); + } + + [Test] + public async Task Headers_should_be_ignored_when_caller_not_in_known_networks() + { + RequestInfoResponse requestInfo = null; + + await Define() + .Done(async ctx => + { + // Simulate request from IP 203.0.113.1 (not in known networks) + // The known networks are 10.0.0.0/8 and 192.168.0.0/16, so this IP should be rejected + requestInfo = await ForwardedHeadersAssertions.GetRequestInfo( + HttpClient, + SerializerOptions, + xForwardedFor: "203.0.113.50", + xForwardedProto: "https", + xForwardedHost: "example.com", + testRemoteIp: "203.0.113.1"); + return requestInfo != null; + }) + .Run(); + + ForwardedHeadersAssertions.AssertHeadersIgnoredWhenProxyNotTrusted( + requestInfo, + sentXForwardedFor: "203.0.113.50", + sentXForwardedProto: "https", + sentXForwardedHost: "example.com"); + + // Verify configuration shows the networks (203.0.113.1 is NOT in these networks) + Assert.That(requestInfo.Configuration.KnownNetworks, Does.Contain("10.0.0.0/8")); + Assert.That(requestInfo.Configuration.KnownNetworks, Does.Contain("192.168.0.0/16")); + } + + class Context : ScenarioContext + { + } + } +} diff --git a/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_unknown_proxy_sends_headers.cs b/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_unknown_proxy_sends_headers.cs new file mode 100644 index 0000000000..1e9dbf3baa --- /dev/null +++ b/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_unknown_proxy_sends_headers.cs @@ -0,0 +1,67 @@ +namespace ServiceControl.AcceptanceTests.Security.ForwardedHeaders +{ + using System.Threading.Tasks; + using AcceptanceTesting; + using AcceptanceTesting.ForwardedHeaders; + using NServiceBus.AcceptanceTesting; + using NUnit.Framework; + + /// + /// Tests Scenario 5: Unknown Proxy Rejected from local-forward-headers-testing.md + /// When KnownProxies are configured but the caller IP does NOT match, headers should be ignored. + /// + class When_unknown_proxy_sends_headers : AcceptanceTest + { + ForwardedHeadersTestConfiguration configuration; + + [SetUp] + public void ConfigureForwardedHeaders() + { + // Configure a known proxy that does NOT match localhost (test server uses localhost) + // This should cause headers to be ignored + configuration = new ForwardedHeadersTestConfiguration(ServiceControlInstanceType.Primary) + .WithKnownProxies("192.168.1.100"); + } + + [TearDown] + public void CleanupForwardedHeaders() + { + configuration?.Dispose(); + } + + [Test] + public async Task Headers_should_be_ignored_when_caller_not_in_known_proxies() + { + RequestInfoResponse requestInfo = null; + + await Define() + .Done(async ctx => + { + // Simulate request from IP 203.0.113.1 (not in known proxies) + // The known proxy is 192.168.1.100, so this IP should be rejected + requestInfo = await ForwardedHeadersAssertions.GetRequestInfo( + HttpClient, + SerializerOptions, + xForwardedFor: "203.0.113.50", + xForwardedProto: "https", + xForwardedHost: "example.com", + testRemoteIp: "203.0.113.1"); + return requestInfo != null; + }) + .Run(); + + ForwardedHeadersAssertions.AssertHeadersIgnoredWhenProxyNotTrusted( + requestInfo, + sentXForwardedFor: "203.0.113.50", + sentXForwardedProto: "https", + sentXForwardedHost: "example.com"); + + // Verify configuration shows the trusted proxy (203.0.113.1 is NOT this proxy) + Assert.That(requestInfo.Configuration.KnownProxies, Does.Contain("192.168.1.100")); + } + + class Context : ScenarioContext + { + } + } +} diff --git a/src/ServiceControl.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs b/src/ServiceControl.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs index ff32483f1f..5ee232a57b 100644 --- a/src/ServiceControl.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs +++ b/src/ServiceControl.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs @@ -2,6 +2,7 @@ { using System; using System.IO; + using System.Net; using System.Net.Http; using System.Runtime.Loader; using System.Text.Json; @@ -127,6 +128,23 @@ async Task InitializeServiceControl(ScenarioContext context) hostBuilderCustomization(hostBuilder); host = hostBuilder.Build(); + + // Test middleware: Set RemoteIpAddress from X-Test-Remote-IP header + // This must run BEFORE UseServiceControl (which adds ForwardedHeaders middleware) + // so that the ForwardedHeaders middleware can properly check KnownProxies/KnownNetworks + host.Use(async (context, next) => + { + if (context.Request.Headers.TryGetValue("X-Test-Remote-IP", out var testIpHeader)) + { + var testIpValue = testIpHeader.ToString(); + if (IPAddress.TryParse(testIpValue, out var testIp)) + { + context.Connection.RemoteIpAddress = testIp; + } + } + await next(); + }); + host.UseServiceControl(settings.ForwardedHeadersSettings, settings.HttpsSettings); await host.StartAsync(); DomainEvents = host.Services.GetRequiredService(); diff --git a/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_combined_proxies_and_networks_are_configured.cs b/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_combined_proxies_and_networks_are_configured.cs new file mode 100644 index 0000000000..91b127d951 --- /dev/null +++ b/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_combined_proxies_and_networks_are_configured.cs @@ -0,0 +1,65 @@ +namespace ServiceControl.Audit.AcceptanceTests.Security.ForwardedHeaders +{ + using System.Threading.Tasks; + using AcceptanceTesting; + using AcceptanceTesting.ForwardedHeaders; + using NServiceBus.AcceptanceTesting; + using NUnit.Framework; + + /// + /// Tests Scenario 10: Combined Known Proxies and Networks from local-forward-headers-testing.md + /// When both KnownProxies and KnownNetworks are configured, matching either grants trust. + /// + class When_combined_proxies_and_networks_are_configured : AcceptanceTest + { + ForwardedHeadersTestConfiguration configuration; + + [SetUp] + public void ConfigureForwardedHeaders() + { + // Configure both proxies (that don't match localhost) and networks (that include localhost) + // The localhost should match via the networks, proving OR logic + configuration = new ForwardedHeadersTestConfiguration(ServiceControlInstanceType.Audit) + .WithKnownProxiesAndNetworks("192.168.1.100", "127.0.0.0/8,::1/128"); + } + + [TearDown] + public void CleanupForwardedHeaders() + { + configuration?.Dispose(); + } + + [Test] + public async Task Headers_should_be_applied_when_caller_matches_network_but_not_proxy() + { + RequestInfoResponse requestInfo = null; + + await Define() + .Done(async ctx => + { + requestInfo = await ForwardedHeadersAssertions.GetRequestInfo( + HttpClient, + SerializerOptions, + xForwardedFor: "203.0.113.50", + xForwardedProto: "https", + xForwardedHost: "example.com"); + return requestInfo != null; + }) + .Run(); + + ForwardedHeadersAssertions.AssertHeadersAppliedWithKnownProxiesOrNetworks( + requestInfo, + expectedScheme: "https", + expectedHost: "example.com", + expectedRemoteIp: "203.0.113.50"); + + // Verify configuration shows both proxies and networks + Assert.That(requestInfo.Configuration.KnownProxies, Does.Contain("192.168.1.100")); + Assert.That(requestInfo.Configuration.KnownNetworks, Does.Contain("127.0.0.0/8").Or.Contain("::1/128")); + } + + class Context : ScenarioContext + { + } + } +} diff --git a/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_forwarded_headers_are_disabled.cs b/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_forwarded_headers_are_disabled.cs new file mode 100644 index 0000000000..859b74e38b --- /dev/null +++ b/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_forwarded_headers_are_disabled.cs @@ -0,0 +1,60 @@ +namespace ServiceControl.Audit.AcceptanceTests.Security.ForwardedHeaders +{ + using System.Threading.Tasks; + using AcceptanceTesting; + using AcceptanceTesting.ForwardedHeaders; + using NServiceBus.AcceptanceTesting; + using NUnit.Framework; + + /// + /// Tests Scenario 7: Forwarded Headers Disabled from local-forward-headers-testing.md + /// When forwarded headers processing is disabled, headers should be ignored regardless of trust. + /// + class When_forwarded_headers_are_disabled : AcceptanceTest + { + ForwardedHeadersTestConfiguration configuration; + + [SetUp] + public void ConfigureForwardedHeaders() + { + // Disable forwarded headers processing entirely + configuration = new ForwardedHeadersTestConfiguration(ServiceControlInstanceType.Audit) + .WithForwardedHeadersDisabled(); + } + + [TearDown] + public void CleanupForwardedHeaders() + { + configuration?.Dispose(); + } + + [Test] + public async Task Headers_should_be_ignored_when_disabled() + { + RequestInfoResponse requestInfo = null; + + await Define() + .Done(async ctx => + { + requestInfo = await ForwardedHeadersAssertions.GetRequestInfo( + HttpClient, + SerializerOptions, + xForwardedFor: "203.0.113.50", + xForwardedProto: "https", + xForwardedHost: "example.com"); + return requestInfo != null; + }) + .Run(); + + ForwardedHeadersAssertions.AssertHeadersIgnoredWhenDisabled( + requestInfo, + sentXForwardedFor: "203.0.113.50", + sentXForwardedProto: "https", + sentXForwardedHost: "example.com"); + } + + class Context : ScenarioContext + { + } + } +} diff --git a/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_forwarded_headers_are_sent.cs b/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_forwarded_headers_are_sent.cs new file mode 100644 index 0000000000..452609c1e6 --- /dev/null +++ b/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_forwarded_headers_are_sent.cs @@ -0,0 +1,44 @@ +namespace ServiceControl.Audit.AcceptanceTests.Security.ForwardedHeaders +{ + using System.Threading.Tasks; + using AcceptanceTesting; + using AcceptanceTesting.ForwardedHeaders; + using NServiceBus.AcceptanceTesting; + using NUnit.Framework; + + /// + /// Tests Scenario 1/2: Default Behavior with Headers from local-forward-headers-testing.md + /// When forwarded headers are sent and TrustAllProxies is true (default), headers should be applied. + /// + class When_forwarded_headers_are_sent : AcceptanceTest + { + [Test] + public async Task Headers_should_be_applied_when_trust_all_proxies() + { + RequestInfoResponse requestInfo = null; + + await Define() + .Done(async ctx => + { + requestInfo = await ForwardedHeadersAssertions.GetRequestInfo( + HttpClient, + SerializerOptions, + xForwardedFor: "203.0.113.50", + xForwardedProto: "https", + xForwardedHost: "example.com"); + return requestInfo != null; + }) + .Run(); + + ForwardedHeadersAssertions.AssertHeadersAppliedWhenTrustAllProxies( + requestInfo, + expectedScheme: "https", + expectedHost: "example.com", + expectedRemoteIp: "203.0.113.50"); + } + + class Context : ScenarioContext + { + } + } +} diff --git a/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_known_networks_are_configured.cs b/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_known_networks_are_configured.cs new file mode 100644 index 0000000000..49b0793b21 --- /dev/null +++ b/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_known_networks_are_configured.cs @@ -0,0 +1,63 @@ +namespace ServiceControl.Audit.AcceptanceTests.Security.ForwardedHeaders +{ + using System.Threading.Tasks; + using AcceptanceTesting; + using AcceptanceTesting.ForwardedHeaders; + using NServiceBus.AcceptanceTesting; + using NUnit.Framework; + + /// + /// Tests Scenario 4: Known Networks (CIDR) from local-forward-headers-testing.md + /// When KnownNetworks are configured and the caller IP falls within, headers should be applied. + /// + class When_known_networks_are_configured : AcceptanceTest + { + ForwardedHeadersTestConfiguration configuration; + + [SetUp] + public void ConfigureForwardedHeaders() + { + // Configure known networks to include localhost CIDR ranges (test server uses localhost) + configuration = new ForwardedHeadersTestConfiguration(ServiceControlInstanceType.Audit) + .WithKnownNetworks("127.0.0.0/8,::1/128"); + } + + [TearDown] + public void CleanupForwardedHeaders() + { + configuration?.Dispose(); + } + + [Test] + public async Task Headers_should_be_applied_when_caller_matches_known_network() + { + RequestInfoResponse requestInfo = null; + + await Define() + .Done(async ctx => + { + requestInfo = await ForwardedHeadersAssertions.GetRequestInfo( + HttpClient, + SerializerOptions, + xForwardedFor: "203.0.113.50", + xForwardedProto: "https", + xForwardedHost: "example.com"); + return requestInfo != null; + }) + .Run(); + + ForwardedHeadersAssertions.AssertHeadersAppliedWithKnownProxiesOrNetworks( + requestInfo, + expectedScheme: "https", + expectedHost: "example.com", + expectedRemoteIp: "203.0.113.50"); + + // Verify configuration shows known networks + Assert.That(requestInfo.Configuration.KnownNetworks, Does.Contain("127.0.0.0/8").Or.Contain("::1/128")); + } + + class Context : ScenarioContext + { + } + } +} diff --git a/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_known_proxies_are_configured.cs b/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_known_proxies_are_configured.cs new file mode 100644 index 0000000000..bb81a6bb8c --- /dev/null +++ b/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_known_proxies_are_configured.cs @@ -0,0 +1,63 @@ +namespace ServiceControl.Audit.AcceptanceTests.Security.ForwardedHeaders +{ + using System.Threading.Tasks; + using AcceptanceTesting; + using AcceptanceTesting.ForwardedHeaders; + using NServiceBus.AcceptanceTesting; + using NUnit.Framework; + + /// + /// Tests Scenario 3: Known Proxies Only from local-forward-headers-testing.md + /// When KnownProxies are configured and the caller IP matches, headers should be applied. + /// + class When_known_proxies_are_configured : AcceptanceTest + { + ForwardedHeadersTestConfiguration configuration; + + [SetUp] + public void ConfigureForwardedHeaders() + { + // Configure known proxies to include localhost addresses (test server uses localhost) + configuration = new ForwardedHeadersTestConfiguration(ServiceControlInstanceType.Audit) + .WithKnownProxies("127.0.0.1,::1"); + } + + [TearDown] + public void CleanupForwardedHeaders() + { + configuration?.Dispose(); + } + + [Test] + public async Task Headers_should_be_applied_when_caller_matches_known_proxy() + { + RequestInfoResponse requestInfo = null; + + await Define() + .Done(async ctx => + { + requestInfo = await ForwardedHeadersAssertions.GetRequestInfo( + HttpClient, + SerializerOptions, + xForwardedFor: "203.0.113.50", + xForwardedProto: "https", + xForwardedHost: "example.com"); + return requestInfo != null; + }) + .Run(); + + ForwardedHeadersAssertions.AssertHeadersAppliedWithKnownProxiesOrNetworks( + requestInfo, + expectedScheme: "https", + expectedHost: "example.com", + expectedRemoteIp: "203.0.113.50"); + + // Verify configuration shows known proxies + Assert.That(requestInfo.Configuration.KnownProxies, Does.Contain("127.0.0.1").Or.Contain("::1")); + } + + class Context : ScenarioContext + { + } + } +} diff --git a/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_only_proto_header_is_sent.cs b/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_only_proto_header_is_sent.cs new file mode 100644 index 0000000000..c5531e6ee7 --- /dev/null +++ b/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_only_proto_header_is_sent.cs @@ -0,0 +1,38 @@ +namespace ServiceControl.Audit.AcceptanceTests.Security.ForwardedHeaders +{ + using System.Threading.Tasks; + using AcceptanceTesting; + using AcceptanceTesting.ForwardedHeaders; + using NServiceBus.AcceptanceTesting; + using NUnit.Framework; + + /// + /// Tests Scenario 11: Partial Headers (Proto Only) from local-forward-headers-testing.md + /// When only X-Forwarded-Proto is sent, only scheme should change. + /// + class When_only_proto_header_is_sent : AcceptanceTest + { + [Test] + public async Task Only_scheme_should_be_changed() + { + RequestInfoResponse requestInfo = null; + + await Define() + .Done(async ctx => + { + requestInfo = await ForwardedHeadersAssertions.GetRequestInfo( + HttpClient, + SerializerOptions, + xForwardedProto: "https"); + return requestInfo != null; + }) + .Run(); + + ForwardedHeadersAssertions.AssertPartialHeadersApplied(requestInfo, expectedScheme: "https"); + } + + class Context : ScenarioContext + { + } + } +} diff --git a/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_proxy_chain_headers_are_sent.cs b/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_proxy_chain_headers_are_sent.cs new file mode 100644 index 0000000000..ef25aa2be0 --- /dev/null +++ b/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_proxy_chain_headers_are_sent.cs @@ -0,0 +1,48 @@ +namespace ServiceControl.Audit.AcceptanceTests.Security.ForwardedHeaders +{ + using System.Threading.Tasks; + using AcceptanceTesting; + using AcceptanceTesting.ForwardedHeaders; + using NServiceBus.AcceptanceTesting; + using NUnit.Framework; + + /// + /// Tests Scenario 8: Proxy Chain (Multiple X-Forwarded-For Values) from local-forward-headers-testing.md + /// When TrustAllProxies is true and X-Forwarded-For contains multiple IPs (proxy chain), + /// the original client IP (first in the chain) should be returned. + /// + class When_proxy_chain_headers_are_sent : AcceptanceTest + { + [Test] + public async Task Original_client_ip_should_be_returned_when_trust_all_proxies() + { + RequestInfoResponse requestInfo = null; + + await Define() + .Done(async ctx => + { + // Simulate a proxy chain: client -> proxy1 -> proxy2 -> ServiceControl.Audit + // X-Forwarded-For: 203.0.113.50, 10.0.0.1, 192.168.1.1 + // Expected: 203.0.113.50 (original client) + requestInfo = await ForwardedHeadersAssertions.GetRequestInfo( + HttpClient, + SerializerOptions, + xForwardedFor: "203.0.113.50, 10.0.0.1, 192.168.1.1", + xForwardedProto: "https", + xForwardedHost: "example.com"); + return requestInfo != null; + }) + .Run(); + + ForwardedHeadersAssertions.AssertProxyChainProcessedWithTrustAllProxies( + requestInfo, + expectedOriginalClientIp: "203.0.113.50", + expectedScheme: "https", + expectedHost: "example.com"); + } + + class Context : ScenarioContext + { + } + } +} diff --git a/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_proxy_chain_headers_are_sent_with_known_proxies.cs b/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_proxy_chain_headers_are_sent_with_known_proxies.cs new file mode 100644 index 0000000000..7d0c0e8b71 --- /dev/null +++ b/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_proxy_chain_headers_are_sent_with_known_proxies.cs @@ -0,0 +1,64 @@ +namespace ServiceControl.Audit.AcceptanceTests.Security.ForwardedHeaders +{ + using System.Threading.Tasks; + using AcceptanceTesting; + using AcceptanceTesting.ForwardedHeaders; + using NServiceBus.AcceptanceTesting; + using NUnit.Framework; + + /// + /// Tests Scenario 9: Proxy Chain with Known Proxies (ForwardLimit=1) from local-forward-headers-testing.md + /// When TrustAllProxies=false (known proxies configured), ForwardLimit=1, so only the last proxy IP is processed. + /// + class When_proxy_chain_headers_are_sent_with_known_proxies : AcceptanceTest + { + ForwardedHeadersTestConfiguration configuration; + + [SetUp] + public void ConfigureForwardedHeaders() + { + // Configure known proxies to include localhost (test server uses localhost) + configuration = new ForwardedHeadersTestConfiguration(ServiceControlInstanceType.Audit) + .WithKnownProxies("127.0.0.1,::1"); + } + + [TearDown] + public void CleanupForwardedHeaders() + { + configuration?.Dispose(); + } + + [Test] + public async Task Only_last_proxy_ip_should_be_processed_when_forward_limit_is_one() + { + RequestInfoResponse requestInfo = null; + + await Define() + .Done(async ctx => + { + // Simulate a proxy chain: client -> proxy1 -> proxy2 -> ServiceControl.Audit + // X-Forwarded-For: 203.0.113.50, 10.0.0.1, 192.168.1.1 + // Expected with ForwardLimit=1: 192.168.1.1 (last proxy in chain) + requestInfo = await ForwardedHeadersAssertions.GetRequestInfo( + HttpClient, + SerializerOptions, + xForwardedFor: "203.0.113.50, 10.0.0.1, 192.168.1.1", + xForwardedProto: "https", + xForwardedHost: "example.com"); + return requestInfo != null; + }) + .Run(); + + ForwardedHeadersAssertions.AssertProxyChainWithForwardLimitOne( + requestInfo, + expectedLastProxyIp: "192.168.1.1", + expectedScheme: "https", + expectedHost: "example.com", + expectedRemainingForwardedFor: "203.0.113.50,10.0.0.1"); + } + + class Context : ScenarioContext + { + } + } +} diff --git a/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_request_has_no_forwarded_headers.cs b/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_request_has_no_forwarded_headers.cs new file mode 100644 index 0000000000..a32f6708f9 --- /dev/null +++ b/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_request_has_no_forwarded_headers.cs @@ -0,0 +1,40 @@ +namespace ServiceControl.Audit.AcceptanceTests.Security.ForwardedHeaders +{ + using System.Threading.Tasks; + using AcceptanceTesting; + using AcceptanceTesting.ForwardedHeaders; + using NServiceBus.AcceptanceTesting; + using NUnit.Framework; + + /// + /// Tests Scenario 0: Direct Access (No Proxy) from local-forward-headers-testing.md + /// When no forwarded headers are sent, the request values should remain unchanged. + /// + class When_request_has_no_forwarded_headers : AcceptanceTest + { + [Test] + public async Task Request_values_should_remain_unchanged() + { + RequestInfoResponse requestInfo = null; + + await Define() + .Done(async ctx => + { + var result = await this.TryGet("/debug/request-info"); + if (result.HasResult) + { + requestInfo = result.Item; + return true; + } + return false; + }) + .Run(); + + ForwardedHeadersAssertions.AssertDirectAccessWithNoForwardedHeaders(requestInfo); + } + + class Context : ScenarioContext + { + } + } +} diff --git a/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_unknown_network_sends_headers.cs b/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_unknown_network_sends_headers.cs new file mode 100644 index 0000000000..dc84189f55 --- /dev/null +++ b/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_unknown_network_sends_headers.cs @@ -0,0 +1,68 @@ +namespace ServiceControl.Audit.AcceptanceTests.Security.ForwardedHeaders +{ + using System.Threading.Tasks; + using AcceptanceTesting; + using AcceptanceTesting.ForwardedHeaders; + using NServiceBus.AcceptanceTesting; + using NUnit.Framework; + + /// + /// Tests Scenario 6: Unknown Network Rejected from local-forward-headers-testing.md + /// When KnownNetworks are configured but the caller IP does NOT fall within, headers should be ignored. + /// + class When_unknown_network_sends_headers : AcceptanceTest + { + ForwardedHeadersTestConfiguration configuration; + + [SetUp] + public void ConfigureForwardedHeaders() + { + // Configure known networks that do NOT include localhost (test server uses localhost) + // This should cause headers to be ignored + configuration = new ForwardedHeadersTestConfiguration(ServiceControlInstanceType.Audit) + .WithKnownNetworks("10.0.0.0/8,192.168.0.0/16"); + } + + [TearDown] + public void CleanupForwardedHeaders() + { + configuration?.Dispose(); + } + + [Test] + public async Task Headers_should_be_ignored_when_caller_not_in_known_networks() + { + RequestInfoResponse requestInfo = null; + + await Define() + .Done(async ctx => + { + // Simulate request from IP 203.0.113.1 (TEST-NET-3, not in known networks) + // The known networks are 10.0.0.0/8 and 192.168.0.0/16, so this IP should be rejected + requestInfo = await ForwardedHeadersAssertions.GetRequestInfo( + HttpClient, + SerializerOptions, + xForwardedFor: "203.0.113.50", + xForwardedProto: "https", + xForwardedHost: "example.com", + testRemoteIp: "203.0.113.1"); + return requestInfo != null; + }) + .Run(); + + ForwardedHeadersAssertions.AssertHeadersIgnoredWhenProxyNotTrusted( + requestInfo, + sentXForwardedFor: "203.0.113.50", + sentXForwardedProto: "https", + sentXForwardedHost: "example.com"); + + // Verify configuration shows the networks (203.0.113.1 is NOT in these networks) + Assert.That(requestInfo.Configuration.KnownNetworks, Does.Contain("10.0.0.0/8")); + Assert.That(requestInfo.Configuration.KnownNetworks, Does.Contain("192.168.0.0/16")); + } + + class Context : ScenarioContext + { + } + } +} diff --git a/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_unknown_proxy_sends_headers.cs b/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_unknown_proxy_sends_headers.cs new file mode 100644 index 0000000000..7518ba311a --- /dev/null +++ b/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_unknown_proxy_sends_headers.cs @@ -0,0 +1,67 @@ +namespace ServiceControl.Audit.AcceptanceTests.Security.ForwardedHeaders +{ + using System.Threading.Tasks; + using AcceptanceTesting; + using AcceptanceTesting.ForwardedHeaders; + using NServiceBus.AcceptanceTesting; + using NUnit.Framework; + + /// + /// Tests Scenario 5: Unknown Proxy Rejected from local-forward-headers-testing.md + /// When KnownProxies are configured but the caller IP does NOT match, headers should be ignored. + /// + class When_unknown_proxy_sends_headers : AcceptanceTest + { + ForwardedHeadersTestConfiguration configuration; + + [SetUp] + public void ConfigureForwardedHeaders() + { + // Configure a known proxy that does NOT match localhost (test server uses localhost) + // This should cause headers to be ignored + configuration = new ForwardedHeadersTestConfiguration(ServiceControlInstanceType.Audit) + .WithKnownProxies("192.168.1.100"); + } + + [TearDown] + public void CleanupForwardedHeaders() + { + configuration?.Dispose(); + } + + [Test] + public async Task Headers_should_be_ignored_when_caller_not_in_known_proxies() + { + RequestInfoResponse requestInfo = null; + + await Define() + .Done(async ctx => + { + // Simulate request from IP 203.0.113.1 (TEST-NET-3, not in known proxies) + // The known proxy is 192.168.1.100, so this IP should be rejected + requestInfo = await ForwardedHeadersAssertions.GetRequestInfo( + HttpClient, + SerializerOptions, + xForwardedFor: "203.0.113.50", + xForwardedProto: "https", + xForwardedHost: "example.com", + testRemoteIp: "203.0.113.1"); + return requestInfo != null; + }) + .Run(); + + ForwardedHeadersAssertions.AssertHeadersIgnoredWhenProxyNotTrusted( + requestInfo, + sentXForwardedFor: "203.0.113.50", + sentXForwardedProto: "https", + sentXForwardedHost: "example.com"); + + // Verify configuration shows the trusted proxy (203.0.113.1 is NOT this proxy) + Assert.That(requestInfo.Configuration.KnownProxies, Does.Contain("192.168.1.100")); + } + + class Context : ScenarioContext + { + } + } +} diff --git a/src/ServiceControl.Audit.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs b/src/ServiceControl.Audit.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs index b2a1bfbbbb..a7d7e27f9c 100644 --- a/src/ServiceControl.Audit.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs +++ b/src/ServiceControl.Audit.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs @@ -4,6 +4,7 @@ namespace ServiceControl.Audit.AcceptanceTests.TestSupport using System.Collections.Generic; using System.Configuration; using System.IO; + using System.Net; using System.Net.Http; using System.Runtime.Loader; using System.Text.Json; @@ -136,6 +137,23 @@ async Task InitializeServiceControl(ScenarioContext context) hostBuilderCustomization(hostBuilder); host = hostBuilder.Build(); + + // Test middleware: Set RemoteIpAddress from X-Test-Remote-IP header + // This must run BEFORE UseServiceControlAudit (which adds ForwardedHeaders middleware) + // so that the ForwardedHeaders middleware can properly check KnownProxies/KnownNetworks + host.Use(async (context, next) => + { + if (context.Request.Headers.TryGetValue("X-Test-Remote-IP", out var testIpHeader)) + { + var testIpValue = testIpHeader.ToString(); + if (IPAddress.TryParse(testIpValue, out var testIp)) + { + context.Connection.RemoteIpAddress = testIp; + } + } + await next(); + }); + host.UseServiceControlAudit(settings.ForwardedHeadersSettings, settings.HttpsSettings); await host.StartAsync(); ServiceProvider = host.Services; diff --git a/src/ServiceControl.Audit.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt b/src/ServiceControl.Audit.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt index b27513bdb0..df1471b757 100644 --- a/src/ServiceControl.Audit.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt +++ b/src/ServiceControl.Audit.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt @@ -27,6 +27,7 @@ "CertificatePath": null, "CertificatePassword": null, "RedirectHttpToHttps": false, + "HttpsPort": null, "EnableHsts": false, "HstsMaxAgeSeconds": 31536000, "HstsIncludeSubDomains": false diff --git a/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_combined_proxies_and_networks_are_configured.cs b/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_combined_proxies_and_networks_are_configured.cs new file mode 100644 index 0000000000..564226d6d3 --- /dev/null +++ b/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_combined_proxies_and_networks_are_configured.cs @@ -0,0 +1,65 @@ +namespace ServiceControl.Monitoring.AcceptanceTests.Security.ForwardedHeaders +{ + using System.Threading.Tasks; + using AcceptanceTesting; + using AcceptanceTesting.ForwardedHeaders; + using NServiceBus.AcceptanceTesting; + using NUnit.Framework; + + /// + /// Tests Scenario 10: Combined Known Proxies and Networks from local-forward-headers-testing.md + /// When both KnownProxies and KnownNetworks are configured, matching either grants trust. + /// + class When_combined_proxies_and_networks_are_configured : AcceptanceTest + { + ForwardedHeadersTestConfiguration configuration; + + [SetUp] + public void ConfigureForwardedHeaders() + { + // Configure both proxies (that don't match localhost) and networks (that include localhost) + // The localhost should match via the networks, proving OR logic + configuration = new ForwardedHeadersTestConfiguration(ServiceControlInstanceType.Monitoring) + .WithKnownProxiesAndNetworks("192.168.1.100", "127.0.0.0/8,::1/128"); + } + + [TearDown] + public void CleanupForwardedHeaders() + { + configuration?.Dispose(); + } + + [Test] + public async Task Headers_should_be_applied_when_caller_matches_network_but_not_proxy() + { + RequestInfoResponse requestInfo = null; + + await Define() + .Done(async ctx => + { + requestInfo = await ForwardedHeadersAssertions.GetRequestInfo( + HttpClient, + SerializerOptions, + xForwardedFor: "203.0.113.50", + xForwardedProto: "https", + xForwardedHost: "example.com"); + return requestInfo != null; + }) + .Run(); + + ForwardedHeadersAssertions.AssertHeadersAppliedWithKnownProxiesOrNetworks( + requestInfo, + expectedScheme: "https", + expectedHost: "example.com", + expectedRemoteIp: "203.0.113.50"); + + // Verify configuration shows both proxies and networks + Assert.That(requestInfo.Configuration.KnownProxies, Does.Contain("192.168.1.100")); + Assert.That(requestInfo.Configuration.KnownNetworks, Does.Contain("127.0.0.0/8").Or.Contain("::1/128")); + } + + class Context : ScenarioContext + { + } + } +} diff --git a/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_forwarded_headers_are_disabled.cs b/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_forwarded_headers_are_disabled.cs new file mode 100644 index 0000000000..77c1122458 --- /dev/null +++ b/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_forwarded_headers_are_disabled.cs @@ -0,0 +1,60 @@ +namespace ServiceControl.Monitoring.AcceptanceTests.Security.ForwardedHeaders +{ + using System.Threading.Tasks; + using AcceptanceTesting; + using AcceptanceTesting.ForwardedHeaders; + using NServiceBus.AcceptanceTesting; + using NUnit.Framework; + + /// + /// Tests Scenario 7: Forwarded Headers Disabled from local-forward-headers-testing.md + /// When forwarded headers processing is disabled, headers should be ignored regardless of trust. + /// + class When_forwarded_headers_are_disabled : AcceptanceTest + { + ForwardedHeadersTestConfiguration configuration; + + [SetUp] + public void ConfigureForwardedHeaders() + { + // Disable forwarded headers processing entirely + configuration = new ForwardedHeadersTestConfiguration(ServiceControlInstanceType.Monitoring) + .WithForwardedHeadersDisabled(); + } + + [TearDown] + public void CleanupForwardedHeaders() + { + configuration?.Dispose(); + } + + [Test] + public async Task Headers_should_be_ignored_when_disabled() + { + RequestInfoResponse requestInfo = null; + + await Define() + .Done(async ctx => + { + requestInfo = await ForwardedHeadersAssertions.GetRequestInfo( + HttpClient, + SerializerOptions, + xForwardedFor: "203.0.113.50", + xForwardedProto: "https", + xForwardedHost: "example.com"); + return requestInfo != null; + }) + .Run(); + + ForwardedHeadersAssertions.AssertHeadersIgnoredWhenDisabled( + requestInfo, + sentXForwardedFor: "203.0.113.50", + sentXForwardedProto: "https", + sentXForwardedHost: "example.com"); + } + + class Context : ScenarioContext + { + } + } +} diff --git a/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_forwarded_headers_are_sent.cs b/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_forwarded_headers_are_sent.cs new file mode 100644 index 0000000000..d22f107c0a --- /dev/null +++ b/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_forwarded_headers_are_sent.cs @@ -0,0 +1,44 @@ +namespace ServiceControl.Monitoring.AcceptanceTests.Security.ForwardedHeaders +{ + using System.Threading.Tasks; + using AcceptanceTesting; + using AcceptanceTesting.ForwardedHeaders; + using NServiceBus.AcceptanceTesting; + using NUnit.Framework; + + /// + /// Tests Scenario 1/2: Default Behavior with Headers from local-forward-headers-testing.md + /// When forwarded headers are sent and TrustAllProxies is true (default), headers should be applied. + /// + class When_forwarded_headers_are_sent : AcceptanceTest + { + [Test] + public async Task Headers_should_be_applied_when_trust_all_proxies() + { + RequestInfoResponse requestInfo = null; + + await Define() + .Done(async ctx => + { + requestInfo = await ForwardedHeadersAssertions.GetRequestInfo( + HttpClient, + SerializerOptions, + xForwardedFor: "203.0.113.50", + xForwardedProto: "https", + xForwardedHost: "example.com"); + return requestInfo != null; + }) + .Run(); + + ForwardedHeadersAssertions.AssertHeadersAppliedWhenTrustAllProxies( + requestInfo, + expectedScheme: "https", + expectedHost: "example.com", + expectedRemoteIp: "203.0.113.50"); + } + + class Context : ScenarioContext + { + } + } +} diff --git a/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_known_networks_are_configured.cs b/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_known_networks_are_configured.cs new file mode 100644 index 0000000000..7b3ae50dcd --- /dev/null +++ b/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_known_networks_are_configured.cs @@ -0,0 +1,63 @@ +namespace ServiceControl.Monitoring.AcceptanceTests.Security.ForwardedHeaders +{ + using System.Threading.Tasks; + using AcceptanceTesting; + using AcceptanceTesting.ForwardedHeaders; + using NServiceBus.AcceptanceTesting; + using NUnit.Framework; + + /// + /// Tests Scenario 4: Known Networks (CIDR) from local-forward-headers-testing.md + /// When KnownNetworks are configured and the caller IP falls within, headers should be applied. + /// + class When_known_networks_are_configured : AcceptanceTest + { + ForwardedHeadersTestConfiguration configuration; + + [SetUp] + public void ConfigureForwardedHeaders() + { + // Configure known networks to include localhost CIDR ranges (test server uses localhost) + configuration = new ForwardedHeadersTestConfiguration(ServiceControlInstanceType.Monitoring) + .WithKnownNetworks("127.0.0.0/8,::1/128"); + } + + [TearDown] + public void CleanupForwardedHeaders() + { + configuration?.Dispose(); + } + + [Test] + public async Task Headers_should_be_applied_when_caller_matches_known_network() + { + RequestInfoResponse requestInfo = null; + + await Define() + .Done(async ctx => + { + requestInfo = await ForwardedHeadersAssertions.GetRequestInfo( + HttpClient, + SerializerOptions, + xForwardedFor: "203.0.113.50", + xForwardedProto: "https", + xForwardedHost: "example.com"); + return requestInfo != null; + }) + .Run(); + + ForwardedHeadersAssertions.AssertHeadersAppliedWithKnownProxiesOrNetworks( + requestInfo, + expectedScheme: "https", + expectedHost: "example.com", + expectedRemoteIp: "203.0.113.50"); + + // Verify configuration shows known networks + Assert.That(requestInfo.Configuration.KnownNetworks, Does.Contain("127.0.0.0/8").Or.Contain("::1/128")); + } + + class Context : ScenarioContext + { + } + } +} diff --git a/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_known_proxies_are_configured.cs b/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_known_proxies_are_configured.cs new file mode 100644 index 0000000000..c62c877c02 --- /dev/null +++ b/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_known_proxies_are_configured.cs @@ -0,0 +1,63 @@ +namespace ServiceControl.Monitoring.AcceptanceTests.Security.ForwardedHeaders +{ + using System.Threading.Tasks; + using AcceptanceTesting; + using AcceptanceTesting.ForwardedHeaders; + using NServiceBus.AcceptanceTesting; + using NUnit.Framework; + + /// + /// Tests Scenario 3: Known Proxies Only from local-forward-headers-testing.md + /// When KnownProxies are configured and the caller IP matches, headers should be applied. + /// + class When_known_proxies_are_configured : AcceptanceTest + { + ForwardedHeadersTestConfiguration configuration; + + [SetUp] + public void ConfigureForwardedHeaders() + { + // Configure known proxies to include localhost addresses (test server uses localhost) + configuration = new ForwardedHeadersTestConfiguration(ServiceControlInstanceType.Monitoring) + .WithKnownProxies("127.0.0.1,::1"); + } + + [TearDown] + public void CleanupForwardedHeaders() + { + configuration?.Dispose(); + } + + [Test] + public async Task Headers_should_be_applied_when_caller_matches_known_proxy() + { + RequestInfoResponse requestInfo = null; + + await Define() + .Done(async ctx => + { + requestInfo = await ForwardedHeadersAssertions.GetRequestInfo( + HttpClient, + SerializerOptions, + xForwardedFor: "203.0.113.50", + xForwardedProto: "https", + xForwardedHost: "example.com"); + return requestInfo != null; + }) + .Run(); + + ForwardedHeadersAssertions.AssertHeadersAppliedWithKnownProxiesOrNetworks( + requestInfo, + expectedScheme: "https", + expectedHost: "example.com", + expectedRemoteIp: "203.0.113.50"); + + // Verify configuration shows known proxies + Assert.That(requestInfo.Configuration.KnownProxies, Does.Contain("127.0.0.1").Or.Contain("::1")); + } + + class Context : ScenarioContext + { + } + } +} diff --git a/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_only_proto_header_is_sent.cs b/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_only_proto_header_is_sent.cs new file mode 100644 index 0000000000..f37cb198ac --- /dev/null +++ b/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_only_proto_header_is_sent.cs @@ -0,0 +1,38 @@ +namespace ServiceControl.Monitoring.AcceptanceTests.Security.ForwardedHeaders +{ + using System.Threading.Tasks; + using AcceptanceTesting; + using AcceptanceTesting.ForwardedHeaders; + using NServiceBus.AcceptanceTesting; + using NUnit.Framework; + + /// + /// Tests Scenario 11: Partial Headers (Proto Only) from local-forward-headers-testing.md + /// When only X-Forwarded-Proto is sent, only scheme should change. + /// + class When_only_proto_header_is_sent : AcceptanceTest + { + [Test] + public async Task Only_scheme_should_be_changed() + { + RequestInfoResponse requestInfo = null; + + await Define() + .Done(async ctx => + { + requestInfo = await ForwardedHeadersAssertions.GetRequestInfo( + HttpClient, + SerializerOptions, + xForwardedProto: "https"); + return requestInfo != null; + }) + .Run(); + + ForwardedHeadersAssertions.AssertPartialHeadersApplied(requestInfo, expectedScheme: "https"); + } + + class Context : ScenarioContext + { + } + } +} diff --git a/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_proxy_chain_headers_are_sent.cs b/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_proxy_chain_headers_are_sent.cs new file mode 100644 index 0000000000..8eb1a50db0 --- /dev/null +++ b/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_proxy_chain_headers_are_sent.cs @@ -0,0 +1,48 @@ +namespace ServiceControl.Monitoring.AcceptanceTests.Security.ForwardedHeaders +{ + using System.Threading.Tasks; + using AcceptanceTesting; + using AcceptanceTesting.ForwardedHeaders; + using NServiceBus.AcceptanceTesting; + using NUnit.Framework; + + /// + /// Tests Scenario 8: Proxy Chain (Multiple X-Forwarded-For Values) from local-forward-headers-testing.md + /// When TrustAllProxies is true and X-Forwarded-For contains multiple IPs (proxy chain), + /// the original client IP (first in the chain) should be returned. + /// + class When_proxy_chain_headers_are_sent : AcceptanceTest + { + [Test] + public async Task Original_client_ip_should_be_returned_when_trust_all_proxies() + { + RequestInfoResponse requestInfo = null; + + await Define() + .Done(async ctx => + { + // Simulate a proxy chain: client -> proxy1 -> proxy2 -> ServiceControl.Monitoring + // X-Forwarded-For: 203.0.113.50, 10.0.0.1, 192.168.1.1 + // Expected: 203.0.113.50 (original client) + requestInfo = await ForwardedHeadersAssertions.GetRequestInfo( + HttpClient, + SerializerOptions, + xForwardedFor: "203.0.113.50, 10.0.0.1, 192.168.1.1", + xForwardedProto: "https", + xForwardedHost: "example.com"); + return requestInfo != null; + }) + .Run(); + + ForwardedHeadersAssertions.AssertProxyChainProcessedWithTrustAllProxies( + requestInfo, + expectedOriginalClientIp: "203.0.113.50", + expectedScheme: "https", + expectedHost: "example.com"); + } + + class Context : ScenarioContext + { + } + } +} diff --git a/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_proxy_chain_headers_are_sent_with_known_proxies.cs b/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_proxy_chain_headers_are_sent_with_known_proxies.cs new file mode 100644 index 0000000000..6fae46dbec --- /dev/null +++ b/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_proxy_chain_headers_are_sent_with_known_proxies.cs @@ -0,0 +1,64 @@ +namespace ServiceControl.Monitoring.AcceptanceTests.Security.ForwardedHeaders +{ + using System.Threading.Tasks; + using AcceptanceTesting; + using AcceptanceTesting.ForwardedHeaders; + using NServiceBus.AcceptanceTesting; + using NUnit.Framework; + + /// + /// Tests Scenario 9: Proxy Chain with Known Proxies (ForwardLimit=1) from local-forward-headers-testing.md + /// When TrustAllProxies=false (known proxies configured), ForwardLimit=1, so only the last proxy IP is processed. + /// + class When_proxy_chain_headers_are_sent_with_known_proxies : AcceptanceTest + { + ForwardedHeadersTestConfiguration configuration; + + [SetUp] + public void ConfigureForwardedHeaders() + { + // Configure known proxies to include localhost (test server uses localhost) + configuration = new ForwardedHeadersTestConfiguration(ServiceControlInstanceType.Monitoring) + .WithKnownProxies("127.0.0.1,::1"); + } + + [TearDown] + public void CleanupForwardedHeaders() + { + configuration?.Dispose(); + } + + [Test] + public async Task Only_last_proxy_ip_should_be_processed_when_forward_limit_is_one() + { + RequestInfoResponse requestInfo = null; + + await Define() + .Done(async ctx => + { + // Simulate a proxy chain: client -> proxy1 -> proxy2 -> ServiceControl.Monitoring + // X-Forwarded-For: 203.0.113.50, 10.0.0.1, 192.168.1.1 + // Expected with ForwardLimit=1: 192.168.1.1 (last proxy in chain) + requestInfo = await ForwardedHeadersAssertions.GetRequestInfo( + HttpClient, + SerializerOptions, + xForwardedFor: "203.0.113.50, 10.0.0.1, 192.168.1.1", + xForwardedProto: "https", + xForwardedHost: "example.com"); + return requestInfo != null; + }) + .Run(); + + ForwardedHeadersAssertions.AssertProxyChainWithForwardLimitOne( + requestInfo, + expectedLastProxyIp: "192.168.1.1", + expectedScheme: "https", + expectedHost: "example.com", + expectedRemainingForwardedFor: "203.0.113.50,10.0.0.1"); + } + + class Context : ScenarioContext + { + } + } +} diff --git a/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_request_has_no_forwarded_headers.cs b/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_request_has_no_forwarded_headers.cs new file mode 100644 index 0000000000..0530d73861 --- /dev/null +++ b/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_request_has_no_forwarded_headers.cs @@ -0,0 +1,40 @@ +namespace ServiceControl.Monitoring.AcceptanceTests.Security.ForwardedHeaders +{ + using System.Threading.Tasks; + using AcceptanceTesting; + using AcceptanceTesting.ForwardedHeaders; + using NServiceBus.AcceptanceTesting; + using NUnit.Framework; + + /// + /// Tests Scenario 0: Direct Access (No Proxy) from local-forward-headers-testing.md + /// When no forwarded headers are sent, the request values should remain unchanged. + /// + class When_request_has_no_forwarded_headers : AcceptanceTest + { + [Test] + public async Task Request_values_should_remain_unchanged() + { + RequestInfoResponse requestInfo = null; + + await Define() + .Done(async ctx => + { + var result = await this.TryGet("/debug/request-info"); + if (result.HasResult) + { + requestInfo = result.Item; + return true; + } + return false; + }) + .Run(); + + ForwardedHeadersAssertions.AssertDirectAccessWithNoForwardedHeaders(requestInfo); + } + + class Context : ScenarioContext + { + } + } +} diff --git a/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_unknown_network_sends_headers.cs b/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_unknown_network_sends_headers.cs new file mode 100644 index 0000000000..7edf72a491 --- /dev/null +++ b/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_unknown_network_sends_headers.cs @@ -0,0 +1,68 @@ +namespace ServiceControl.Monitoring.AcceptanceTests.Security.ForwardedHeaders +{ + using System.Threading.Tasks; + using AcceptanceTesting; + using AcceptanceTesting.ForwardedHeaders; + using NServiceBus.AcceptanceTesting; + using NUnit.Framework; + + /// + /// Tests Scenario 6: Unknown Network Rejected from local-forward-headers-testing.md + /// When KnownNetworks are configured but the caller IP does NOT fall within, headers should be ignored. + /// + class When_unknown_network_sends_headers : AcceptanceTest + { + ForwardedHeadersTestConfiguration configuration; + + [SetUp] + public void ConfigureForwardedHeaders() + { + // Configure known networks that do NOT include localhost (test server uses localhost) + // This should cause headers to be ignored + configuration = new ForwardedHeadersTestConfiguration(ServiceControlInstanceType.Monitoring) + .WithKnownNetworks("10.0.0.0/8,192.168.0.0/16"); + } + + [TearDown] + public void CleanupForwardedHeaders() + { + configuration?.Dispose(); + } + + [Test] + public async Task Headers_should_be_ignored_when_caller_not_in_known_networks() + { + RequestInfoResponse requestInfo = null; + + await Define() + .Done(async ctx => + { + // Simulate request from IP 203.0.113.1 (TEST-NET-3, not in known networks) + // The known networks are 10.0.0.0/8 and 192.168.0.0/16, so this IP should be rejected + requestInfo = await ForwardedHeadersAssertions.GetRequestInfo( + HttpClient, + SerializerOptions, + xForwardedFor: "203.0.113.50", + xForwardedProto: "https", + xForwardedHost: "example.com", + testRemoteIp: "203.0.113.1"); + return requestInfo != null; + }) + .Run(); + + ForwardedHeadersAssertions.AssertHeadersIgnoredWhenProxyNotTrusted( + requestInfo, + sentXForwardedFor: "203.0.113.50", + sentXForwardedProto: "https", + sentXForwardedHost: "example.com"); + + // Verify configuration shows the networks (203.0.113.1 is NOT in these networks) + Assert.That(requestInfo.Configuration.KnownNetworks, Does.Contain("10.0.0.0/8")); + Assert.That(requestInfo.Configuration.KnownNetworks, Does.Contain("192.168.0.0/16")); + } + + class Context : ScenarioContext + { + } + } +} diff --git a/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_unknown_proxy_sends_headers.cs b/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_unknown_proxy_sends_headers.cs new file mode 100644 index 0000000000..d5fae3c7f0 --- /dev/null +++ b/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_unknown_proxy_sends_headers.cs @@ -0,0 +1,67 @@ +namespace ServiceControl.Monitoring.AcceptanceTests.Security.ForwardedHeaders +{ + using System.Threading.Tasks; + using AcceptanceTesting; + using AcceptanceTesting.ForwardedHeaders; + using NServiceBus.AcceptanceTesting; + using NUnit.Framework; + + /// + /// Tests Scenario 5: Unknown Proxy Rejected from local-forward-headers-testing.md + /// When KnownProxies are configured but the caller IP does NOT match, headers should be ignored. + /// + class When_unknown_proxy_sends_headers : AcceptanceTest + { + ForwardedHeadersTestConfiguration configuration; + + [SetUp] + public void ConfigureForwardedHeaders() + { + // Configure a known proxy that does NOT match localhost (test server uses localhost) + // This should cause headers to be ignored + configuration = new ForwardedHeadersTestConfiguration(ServiceControlInstanceType.Monitoring) + .WithKnownProxies("192.168.1.100"); + } + + [TearDown] + public void CleanupForwardedHeaders() + { + configuration?.Dispose(); + } + + [Test] + public async Task Headers_should_be_ignored_when_caller_not_in_known_proxies() + { + RequestInfoResponse requestInfo = null; + + await Define() + .Done(async ctx => + { + // Simulate request from IP 203.0.113.1 (TEST-NET-3, not in known proxies) + // The known proxy is 192.168.1.100, so this IP should be rejected + requestInfo = await ForwardedHeadersAssertions.GetRequestInfo( + HttpClient, + SerializerOptions, + xForwardedFor: "203.0.113.50", + xForwardedProto: "https", + xForwardedHost: "example.com", + testRemoteIp: "203.0.113.1"); + return requestInfo != null; + }) + .Run(); + + ForwardedHeadersAssertions.AssertHeadersIgnoredWhenProxyNotTrusted( + requestInfo, + sentXForwardedFor: "203.0.113.50", + sentXForwardedProto: "https", + sentXForwardedHost: "example.com"); + + // Verify configuration shows the trusted proxy (203.0.113.1 is NOT this proxy) + Assert.That(requestInfo.Configuration.KnownProxies, Does.Contain("192.168.1.100")); + } + + class Context : ScenarioContext + { + } + } +} diff --git a/src/ServiceControl.Monitoring.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs b/src/ServiceControl.Monitoring.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs index eb2f491072..ac73dae8af 100644 --- a/src/ServiceControl.Monitoring.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs +++ b/src/ServiceControl.Monitoring.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs @@ -2,6 +2,7 @@ namespace ServiceControl.Monitoring.AcceptanceTests.TestSupport { using System; using System.IO; + using System.Net; using System.Net.Http; using System.Text.Json; using System.Threading; @@ -113,6 +114,23 @@ async Task InitializeServiceControl(ScenarioContext context) hostBuilder.AddServiceControlMonitoringTesting(settings); host = hostBuilder.Build(); + + // Test middleware: Set RemoteIpAddress from X-Test-Remote-IP header + // This must run BEFORE UseServiceControlMonitoring (which adds ForwardedHeaders middleware) + // so that the ForwardedHeaders middleware can properly check KnownProxies/KnownNetworks + host.Use(async (context, next) => + { + if (context.Request.Headers.TryGetValue("X-Test-Remote-IP", out var testIpHeader)) + { + var testIpValue = testIpHeader.ToString(); + if (IPAddress.TryParse(testIpValue, out var testIp)) + { + context.Connection.RemoteIpAddress = testIp; + } + } + await next(); + }); + host.UseServiceControlMonitoring(settings.ForwardedHeadersSettings, settings.HttpsSettings, settings.CorsSettings); await host.StartAsync(); diff --git a/src/ServiceControl.Monitoring.UnitTests/ApprovalFiles/SettingsTests.PlatformSampleSettings.approved.txt b/src/ServiceControl.Monitoring.UnitTests/ApprovalFiles/SettingsTests.PlatformSampleSettings.approved.txt index ffdbac2332..0a464e0f3b 100644 --- a/src/ServiceControl.Monitoring.UnitTests/ApprovalFiles/SettingsTests.PlatformSampleSettings.approved.txt +++ b/src/ServiceControl.Monitoring.UnitTests/ApprovalFiles/SettingsTests.PlatformSampleSettings.approved.txt @@ -27,6 +27,7 @@ "CertificatePath": null, "CertificatePassword": null, "RedirectHttpToHttps": false, + "HttpsPort": null, "EnableHsts": false, "HstsMaxAgeSeconds": 31536000, "HstsIncludeSubDomains": false diff --git a/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt b/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt index 9ad5f0d751..c08e5690dd 100644 --- a/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt +++ b/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt @@ -27,6 +27,7 @@ "CertificatePath": null, "CertificatePassword": null, "RedirectHttpToHttps": false, + "HttpsPort": null, "EnableHsts": false, "HstsMaxAgeSeconds": 31536000, "HstsIncludeSubDomains": false diff --git a/src/ServiceControl.UnitTests/Infrastructure/Settings/ForwardedHeadersSettingsTests.cs b/src/ServiceControl.UnitTests/Infrastructure/Settings/ForwardedHeadersSettingsTests.cs new file mode 100644 index 0000000000..debc45200e --- /dev/null +++ b/src/ServiceControl.UnitTests/Infrastructure/Settings/ForwardedHeadersSettingsTests.cs @@ -0,0 +1,163 @@ +namespace ServiceControl.UnitTests.Infrastructure.Settings; + +using System; +using System.Linq; +using NUnit.Framework; +using ServiceControl.Configuration; +using ServiceControl.Infrastructure; + +/// +/// Tests for which is shared infrastructure code +/// used by all three instance types (ServiceControl, ServiceControl.Audit, ServiceControl.Monitoring). +/// Each instance passes a different which only affects +/// the environment variable prefix (e.g., SERVICECONTROL_, SERVICECONTROL_AUDIT_, MONITORING_). +/// The parsing logic is identical, so testing with one namespace is sufficient. +/// +[TestFixture] +public class ForwardedHeadersSettingsTests +{ + static readonly SettingsRootNamespace TestNamespace = new("ServiceControl"); + + [TearDown] + public void TearDown() + { + // Clean up environment variables after each test + Environment.SetEnvironmentVariable("SERVICECONTROL_FORWARDEDHEADERS_ENABLED", null); + Environment.SetEnvironmentVariable("SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES", null); + Environment.SetEnvironmentVariable("SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES", null); + Environment.SetEnvironmentVariable("SERVICECONTROL_FORWARDEDHEADERS_KNOWNNETWORKS", null); + } + + [Test] + public void Should_parse_known_proxies_from_comma_separated_list() + { + Environment.SetEnvironmentVariable("SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES", "127.0.0.1,10.0.0.5,192.168.1.1"); + + var settings = new ForwardedHeadersSettings(TestNamespace); + + Assert.That(settings.KnownProxiesRaw, Has.Count.EqualTo(3)); + Assert.That(settings.KnownProxiesRaw, Does.Contain("127.0.0.1")); + Assert.That(settings.KnownProxiesRaw, Does.Contain("10.0.0.5")); + Assert.That(settings.KnownProxiesRaw, Does.Contain("192.168.1.1")); + } + + [Test] + public void Should_parse_known_proxies_to_ip_addresses() + { + Environment.SetEnvironmentVariable("SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES", "127.0.0.1,10.0.0.5"); + + var settings = new ForwardedHeadersSettings(TestNamespace); + var ipAddresses = settings.KnownProxies.ToList(); + + Assert.That(ipAddresses, Has.Count.EqualTo(2)); + Assert.That(ipAddresses[0].ToString(), Is.EqualTo("127.0.0.1")); + Assert.That(ipAddresses[1].ToString(), Is.EqualTo("10.0.0.5")); + } + + [Test] + public void Should_ignore_invalid_ip_addresses() + { + Environment.SetEnvironmentVariable("SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES", "127.0.0.1,not-an-ip,10.0.0.5"); + + var settings = new ForwardedHeadersSettings(TestNamespace); + + Assert.That(settings.KnownProxiesRaw, Has.Count.EqualTo(2)); + Assert.That(settings.KnownProxiesRaw, Does.Contain("127.0.0.1")); + Assert.That(settings.KnownProxiesRaw, Does.Contain("10.0.0.5")); + Assert.That(settings.KnownProxiesRaw, Does.Not.Contain("not-an-ip")); + } + + [Test] + public void Should_parse_known_networks_from_comma_separated_cidr() + { + Environment.SetEnvironmentVariable("SERVICECONTROL_FORWARDEDHEADERS_KNOWNNETWORKS", "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"); + + var settings = new ForwardedHeadersSettings(TestNamespace); + + Assert.That(settings.KnownNetworks, Has.Count.EqualTo(3)); + Assert.That(settings.KnownNetworks, Does.Contain("10.0.0.0/8")); + Assert.That(settings.KnownNetworks, Does.Contain("172.16.0.0/12")); + Assert.That(settings.KnownNetworks, Does.Contain("192.168.0.0/16")); + } + + [Test] + public void Should_ignore_invalid_network_cidr() + { + Environment.SetEnvironmentVariable("SERVICECONTROL_FORWARDEDHEADERS_KNOWNNETWORKS", "10.0.0.0/8,invalid-network,172.16.0.0/12"); + + var settings = new ForwardedHeadersSettings(TestNamespace); + + Assert.That(settings.KnownNetworks, Has.Count.EqualTo(2)); + Assert.That(settings.KnownNetworks, Does.Contain("10.0.0.0/8")); + Assert.That(settings.KnownNetworks, Does.Contain("172.16.0.0/12")); + Assert.That(settings.KnownNetworks, Does.Not.Contain("invalid-network")); + } + + [Test] + public void Should_disable_trust_all_proxies_when_known_proxies_configured() + { + Environment.SetEnvironmentVariable("SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES", "127.0.0.1"); + + var settings = new ForwardedHeadersSettings(TestNamespace); + + Assert.That(settings.TrustAllProxies, Is.False); + } + + [Test] + public void Should_disable_trust_all_proxies_when_known_networks_configured() + { + Environment.SetEnvironmentVariable("SERVICECONTROL_FORWARDEDHEADERS_KNOWNNETWORKS", "10.0.0.0/8"); + + var settings = new ForwardedHeadersSettings(TestNamespace); + + Assert.That(settings.TrustAllProxies, Is.False); + } + + [Test] + public void Should_default_to_enabled() + { + var settings = new ForwardedHeadersSettings(TestNamespace); + + Assert.That(settings.Enabled, Is.True); + } + + [Test] + public void Should_default_to_trust_all_proxies() + { + var settings = new ForwardedHeadersSettings(TestNamespace); + + Assert.That(settings.TrustAllProxies, Is.True); + } + + [Test] + public void Should_respect_explicit_disabled_setting() + { + Environment.SetEnvironmentVariable("SERVICECONTROL_FORWARDEDHEADERS_ENABLED", "false"); + + var settings = new ForwardedHeadersSettings(TestNamespace); + + Assert.That(settings.Enabled, Is.False); + } + + [Test] + public void Should_handle_semicolon_separator_in_proxies() + { + Environment.SetEnvironmentVariable("SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES", "127.0.0.1;10.0.0.5"); + + var settings = new ForwardedHeadersSettings(TestNamespace); + + Assert.That(settings.KnownProxiesRaw, Has.Count.EqualTo(2)); + } + + [Test] + public void Should_trim_whitespace_from_proxy_entries() + { + Environment.SetEnvironmentVariable("SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES", " 127.0.0.1 , 10.0.0.5 "); + + var settings = new ForwardedHeadersSettings(TestNamespace); + + Assert.That(settings.KnownProxiesRaw, Has.Count.EqualTo(2)); + Assert.That(settings.KnownProxiesRaw, Does.Contain("127.0.0.1")); + Assert.That(settings.KnownProxiesRaw, Does.Contain("10.0.0.5")); + } +} From 3b33ba5b7024dd44fc8105c53fbe96b764e86815 Mon Sep 17 00:00:00 2001 From: Warwick Schroeder Date: Wed, 17 Dec 2025 08:00:15 +0800 Subject: [PATCH 22/24] Add more manual testing scenarios to docs. Rename files. --- README.md | 8 +- docs/authentication-testing.md | 829 ++++++++++++++++++ ...-testing.md => forward-headers-testing.md} | 2 +- docs/forwarded-headers.md | 4 +- docs/https-configuration.md | 8 +- ...ocal-https-testing.md => https-testing.md} | 6 +- docs/local-authentication-testing.md | 396 --------- ...oxy-testing.md => reverseproxy-testing.md} | 2 +- docs/testing-architecture.md | 2 +- 9 files changed, 845 insertions(+), 412 deletions(-) create mode 100644 docs/authentication-testing.md rename docs/{local-forward-headers-testing.md => forward-headers-testing.md} (99%) rename docs/{local-https-testing.md => https-testing.md} (96%) delete mode 100644 docs/local-authentication-testing.md rename docs/{local-reverseproxy-testing.md => reverseproxy-testing.md} (99%) diff --git a/README.md b/README.md index 714d2c0e20..7c0837011a 100644 --- a/README.md +++ b/README.md @@ -66,10 +66,10 @@ Documentation for configuring security features: Local testing guides: -- [Local HTTPS Testing](docs/local-https-testing.md) -- [Local Reverse Proxy Testing](docs/local-reverseproxy-testing.md) -- [Local Forward Headers Testing](docs/local-forward-headers-testing.md) -- [Local Authentication Testing](docs/local-authentication-testing.md) +- [HTTPS Testing](docs/https-testing.md) +- [Reverse Proxy Testing](docs/reverseproxy-testing.md) +- [Forward Headers Testing](docs/forward-headers-testing.md) +- [Authentication Testing](docs/authentication-testing.md) ## How to developer test the PowerShell Module diff --git a/docs/authentication-testing.md b/docs/authentication-testing.md new file mode 100644 index 0000000000..5c0ae8a44f --- /dev/null +++ b/docs/authentication-testing.md @@ -0,0 +1,829 @@ +# Local Testing Authentication + +This guide explains how to test authentication configuration for ServiceControl instances. This approach uses curl to test authentication enforcement and configuration endpoints. + +## Prerequisites + +- ServiceControl built locally (see main README for build instructions) +- **HTTPS configured** - Authentication should only be used over HTTPS. Configure HTTPS using one of the methods described in [HTTPS Configuration](https-configuration.md) before testing authentication scenarios. +- curl (included with Windows 10/11, Git Bash, or WSL) +- (Optional) For formatted JSON output: `npm install -g json` then pipe curl output through `| json` +- (Optional) An OIDC provider for full end-to-end testing (e.g., Microsoft Entra ID, Auth0, Okta) + +## Instance Reference + +| Instance | Project Directory | Default Port | Environment Variable Prefix | +|----------|-------------------|--------------|----------------------------| +| ServiceControl (Primary) | `src\ServiceControl` | 33333 | `SERVICECONTROL_` | +| ServiceControl.Audit | `src\ServiceControl.Audit` | 44444 | `SERVICECONTROL_AUDIT_` | +| ServiceControl.Monitoring | `src\ServiceControl.Monitoring` | 33633 | `MONITORING_` | + +## How Authentication Works + +When authentication is enabled: + +1. All API requests must include a valid JWT bearer token in the `Authorization` header +2. ServiceControl validates the token against the configured OIDC authority +3. Requests without a valid token receive a `401 Unauthorized` response +4. The `/api/authentication/configuration` endpoint returns authentication configuration for clients (like ServicePulse) + +## Configuration Methods + +Settings can be configured via: + +1. **Environment variables** (recommended for testing) - Easy to change between scenarios, no file edits needed +2. **App.config** - Persisted settings, requires app restart after changes + +Both methods work identically. This guide uses environment variables for convenience during iterative testing. + +## Test Scenarios + +The following scenarios use ServiceControl (Primary) as an example. To test other instances, use the appropriate environment variable prefix and port. + +> **Important:** Set environment variables in the same terminal where you run `dotnet run`. Environment variables are scoped to the terminal session. +> +> **Tip:** Check the application startup logs to verify which settings were applied. The authentication configuration is logged at startup. + +### Scenario 1: Authentication Disabled (Default) + +Test the default behavior where authentication is disabled and all requests are allowed. + +**Clear environment variables and start ServiceControl:** + +```cmd +set SERVICECONTROL_AUTHENTICATION_ENABLED= +set SERVICECONTROL_AUTHENTICATION_AUTHORITY= +set SERVICECONTROL_AUTHENTICATION_AUDIENCE= +set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID= +set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES= +set SERVICECONTROL_AUTHENTICATION_REQUIREHTTPSMETADATA= +set SERVICECONTROL_AUTHENTICATION_VALIDATEISSUER= +set SERVICECONTROL_AUTHENTICATION_VALIDATEAUDIENCE= + +cd src\ServiceControl +dotnet run +``` + +**Test with curl (no authorization header):** + +```cmd +curl http://localhost:33333/api | json +``` + +**Expected output:** + +```json +{ + "description": "The management backend for the Particular Service Platform", + ... +} +``` + +Requests succeed without authentication because `Authentication.Enabled` defaults to `false`. + +**Check authentication configuration endpoint:** + +```cmd +curl http://localhost:33333/api/authentication/configuration | json +``` + +**Expected output:** + +```json +{ + "enabled": false +} +``` + +The configuration indicates authentication is disabled. Other fields are omitted when null. + +### Scenario 2: Authentication Enabled (No Token) + +Test that requests without a token are rejected when authentication is enabled. + +> **Note:** This scenario requires a valid OIDC authority URL. For testing authentication enforcement without a real provider, you can use any HTTP URL - the request will fail before token validation because no token is provided. + +**Clear environment variables and start ServiceControl:** + +```cmd +set SERVICECONTROL_AUTHENTICATION_ENABLED=true +set SERVICECONTROL_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/common/v2.0 +set SERVICECONTROL_AUTHENTICATION_AUDIENCE=api://servicecontrol-test +set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID=test-client-id +set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES=["api://servicecontrol-test/.default"] +set SERVICECONTROL_AUTHENTICATION_REQUIREHTTPSMETADATA= +set SERVICECONTROL_AUTHENTICATION_VALIDATEISSUER= +set SERVICECONTROL_AUTHENTICATION_VALIDATEAUDIENCE= + +cd src\ServiceControl +dotnet run +``` + +**Test with curl (no authorization header):** + +```cmd +curl -v http://localhost:33333/api/endpoints 2>&1 | findstr /C:"HTTP/" +``` + +**Expected output:** + +```text +< HTTP/1.1 401 Unauthorized +``` + +Requests without a token are rejected with `401 Unauthorized`. + +> **Note:** The `/api` root endpoint and `/api/authentication/configuration` are marked as anonymous and will return 200 OK even with authentication enabled. Test protected endpoints like `/api/endpoints` to verify authentication enforcement. + +**Check authentication configuration endpoint (no auth required):** + +```cmd +curl http://localhost:33333/api/authentication/configuration | json +``` + +**Expected output:** + +```json +{ + "enabled": true, + "clientId": "test-client-id", + "audience": "api://servicecontrol-test", + "apiScopes": "[\"api://servicecontrol-test/.default\"]" +} +``` + +The authentication configuration endpoint is accessible without authentication and returns the configuration that clients need to authenticate. The `authority` field is omitted when `ServicePulse.Authority` is not explicitly set (it defaults to the main Authority for ServicePulse clients). + +### Scenario 3: Authentication with Invalid Token + +Test that requests with an invalid token are rejected. + +**Start ServiceControl with authentication enabled (same as Scenario 2):** + +```cmd +set SERVICECONTROL_AUTHENTICATION_ENABLED=true +set SERVICECONTROL_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/common/v2.0 +set SERVICECONTROL_AUTHENTICATION_AUDIENCE=api://servicecontrol-test +set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID=test-client-id +set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES=["api://servicecontrol-test/.default"] +set SERVICECONTROL_AUTHENTICATION_REQUIREHTTPSMETADATA= +set SERVICECONTROL_AUTHENTICATION_VALIDATEISSUER= +set SERVICECONTROL_AUTHENTICATION_VALIDATEAUDIENCE= + +cd src\ServiceControl +dotnet run +``` + +**Test with curl (invalid token):** + +```cmd +curl -v -H "Authorization: Bearer invalid-token-here" http://localhost:33333/api/endpoints 2>&1 | findstr /C:"HTTP/" +``` + +**Expected output:** + +```text +< HTTP/1.1 401 Unauthorized +``` + +Invalid tokens are rejected with `401 Unauthorized`. + +### Scenario 4: Anonymous Endpoints + +Test that anonymous endpoints remain accessible when authentication is enabled. + +**With ServiceControl still running from Scenario 2 or 3, test anonymous endpoints:** + +```cmd +curl http://localhost:33333/api | json +``` + +**Expected output:** + +```json +{ + "description": "The management backend for the Particular Service Platform", + ... +} +``` + +```cmd +curl http://localhost:33333/api/authentication/configuration | json +``` + +**Expected output:** + +```json +{ + "enabled": true, + "clientId": "test-client-id", + "audience": "api://servicecontrol-test", + "apiScopes": "[\"api://servicecontrol-test/.default\"]" +} +``` + +The following endpoints are marked as anonymous and accessible without authentication: + +| Endpoint | Purpose | +|----------|---------| +| `/api` | API root/discovery - returns available endpoints | +| `/api/authentication/configuration` | Returns auth config for clients like ServicePulse | + +### Scenario 5: Validation Settings Warnings + +Test that disabling validation settings produces warnings in the logs. + +**Start ServiceControl with relaxed validation:** + +```cmd +set SERVICECONTROL_AUTHENTICATION_ENABLED=true +set SERVICECONTROL_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/common/v2.0 +set SERVICECONTROL_AUTHENTICATION_AUDIENCE=api://servicecontrol-test +set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID=test-client-id +set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES=["api://servicecontrol-test/.default"] +set SERVICECONTROL_AUTHENTICATION_REQUIREHTTPSMETADATA= +set SERVICECONTROL_AUTHENTICATION_VALIDATEISSUER=false +set SERVICECONTROL_AUTHENTICATION_VALIDATEAUDIENCE=false + +cd src\ServiceControl +dotnet run +``` + +**Expected log output:** + +```text +warn: Authentication.ValidateIssuer is set to false. This is not recommended for production environments... +warn: Authentication.ValidateAudience is set to false. This is not recommended for production environments... +``` + +The application warns about insecure validation settings. + +### Scenario 6: Missing Required Settings + +Test that missing required settings prevent startup. + +**Start ServiceControl with missing authority:** + +```cmd +set SERVICECONTROL_AUTHENTICATION_ENABLED=true +set SERVICECONTROL_AUTHENTICATION_AUTHORITY= +set SERVICECONTROL_AUTHENTICATION_AUDIENCE=api://servicecontrol-test +set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID=test-client-id +set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES=["api://servicecontrol-test/.default"] +set SERVICECONTROL_AUTHENTICATION_REQUIREHTTPSMETADATA= +set SERVICECONTROL_AUTHENTICATION_VALIDATEISSUER= +set SERVICECONTROL_AUTHENTICATION_VALIDATEAUDIENCE= + +cd src\ServiceControl +dotnet run +``` + +**Expected behavior:** + +The application fails to start with an error message: + +```text +Authentication.Authority is required when authentication is enabled. Please provide a valid OpenID Connect authority URL... +``` + +### Scenario 7: Authentication with Valid Token (Real Identity Provider) + +Test end-to-end authentication with a valid token from a real OIDC provider. + +> **Prerequisites:** This scenario requires a configured OIDC provider (e.g., Microsoft Entra ID, Auth0, Okta). + +**Microsoft Entra ID Setup (one-time):** + +1. **Create an App Registration** for ServiceControl API: + - Go to Azure Portal > Microsoft Entra ID > App registrations + - Create a new registration (e.g., "ServiceControl API") + - Note the Application (client) ID and Directory (tenant) ID + - Under "Expose an API", add a scope (e.g., `access_as_user`) + +2. **Create an App Registration** for testing (or use ServicePulse's): + - Create another registration for the client application + - Under "API permissions", add permission to your ServiceControl API scope + - Under "Authentication", enable "Allow public client flows" for testing + +**Start ServiceControl with your Entra ID configuration:** + +```cmd +set SERVICECONTROL_AUTHENTICATION_ENABLED=true +set SERVICECONTROL_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0 +set SERVICECONTROL_AUTHENTICATION_AUDIENCE=api://servicecontrol +set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID={servicepulse-client-id} +set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES=["api://servicecontrol/access_as_user"] +set SERVICECONTROL_AUTHENTICATION_REQUIREHTTPSMETADATA= +set SERVICECONTROL_AUTHENTICATION_VALIDATEISSUER= +set SERVICECONTROL_AUTHENTICATION_VALIDATEAUDIENCE= + +cd src\ServiceControl +dotnet run +``` + +**Get a test token using Azure CLI:** + +```cmd +az login +az account get-access-token --resource api://servicecontrol --query accessToken -o tsv +``` + +**Test with the token:** + +```cmd +curl -H "Authorization: Bearer {token}" http://localhost:33333/api/endpoints | json +``` + +**Expected output:** + +```json +[] +``` + +Requests with a valid token are processed successfully. The response will be an empty array if no endpoints are registered, or a list of endpoints if data exists. + +## Multi-Instance Scenarios + +The following scenarios test authentication behavior when the primary instance communicates with remote Audit and Monitoring instances. + +### Scenario 8: Scatter-Gather with Authentication (Token Forwarding) + +Test that the primary instance forwards authentication tokens to remote instances during scatter-gather operations. + +> **Background:** When a client queries endpoints like `/api/messages`, the primary instance may query remote Audit instances to aggregate results. The client's authorization token is forwarded to these remote instances. + +**Prerequisites:** + +- A configured OIDC provider with valid tokens +- All instances configured with the **same** Authority and Audience settings + +**Terminal 1 - Start ServiceControl.Audit with authentication:** + +```cmd +set SERVICECONTROL_AUDIT_AUTHENTICATION_ENABLED=true +set SERVICECONTROL_AUDIT_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0 +set SERVICECONTROL_AUDIT_AUTHENTICATION_AUDIENCE=api://servicecontrol + +cd src\ServiceControl.Audit +dotnet run +``` + +**Terminal 2 - Start ServiceControl (Primary) with authentication and remote instance configured:** + +```cmd +set SERVICECONTROL_AUTHENTICATION_ENABLED=true +set SERVICECONTROL_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0 +set SERVICECONTROL_AUTHENTICATION_AUDIENCE=api://servicecontrol +set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID={servicepulse-client-id} +set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES=["api://servicecontrol/access_as_user"] +set SERVICECONTROL_REMOTEINSTANCES=[{"api_uri":"https://localhost:44444"}] + +cd src\ServiceControl +dotnet run +``` + +**Get a test token and query the primary instance:** + +```cmd +az login +set TOKEN=$(az account get-access-token --resource api://servicecontrol --query accessToken -o tsv) +curl -H "Authorization: Bearer %TOKEN%" https://localhost:33333/api/messages | json +``` + +**How to verify token forwarding is working:** + +1. **Check the Audit instance logs (Terminal 1)** - When the request succeeds, you should see log entries showing the authenticated request was processed. Look for request logging that shows the `/api/messages` endpoint was called. + +2. **Check the response headers** - The aggregated response includes instance information: + + ```cmd + curl -v -H "Authorization: Bearer %TOKEN%" https://localhost:33333/api/messages 2>&1 | findstr /C:"X-Particular" + ``` + + You should see headers indicating responses were received from remote instances. + +3. **Verify by stopping the Audit instance** - Stop the Audit instance and repeat the request. The response should now only contain local data, and the primary instance logs should show the remote is unavailable. + +4. **Test direct access to Audit instance** - Verify the Audit instance requires authentication independently: + + ```cmd + REM Without token - should fail + curl -v https://localhost:44444/api/messages 2>&1 | findstr /C:"HTTP/" + REM Expected: < HTTP/1.1 401 Unauthorized + + REM With token - should succeed + curl -H "Authorization: Bearer %TOKEN%" https://localhost:44444/api/messages | json + REM Expected: [] or list of messages + ``` + +5. **Compare results** - If authentication forwarding is working correctly: + - Direct request to Audit with token: succeeds + - Direct request to Audit without token: fails with 401 + - Request through Primary with token: succeeds and includes Audit data + - Request through Primary without token: fails with 401 + +**Test with no token (should fail):** + +```cmd +curl -v https://localhost:33333/api/messages 2>&1 | findstr /C:"HTTP/" +``` + +**Expected output:** + +```text +< HTTP/1.1 401 Unauthorized +``` + +### Scenario 9: Scatter-Gather with Mismatched Authentication Configuration + +Test that scatter-gather fails gracefully when remote instances have different authentication settings. + +**Terminal 1 - Start ServiceControl.Audit with DIFFERENT audience:** + +```cmd +set SERVICECONTROL_AUDIT_AUTHENTICATION_ENABLED=true +set SERVICECONTROL_AUDIT_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0 +set SERVICECONTROL_AUDIT_AUTHENTICATION_AUDIENCE=api://servicecontrol-audit-different + +cd src\ServiceControl.Audit +dotnet run +``` + +**Terminal 2 - Start ServiceControl (Primary):** + +```cmd +set SERVICECONTROL_AUTHENTICATION_ENABLED=true +set SERVICECONTROL_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0 +set SERVICECONTROL_AUTHENTICATION_AUDIENCE=api://servicecontrol +set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID={servicepulse-client-id} +set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES=["api://servicecontrol/access_as_user"] +set SERVICECONTROL_REMOTEINSTANCES=[{"api_uri":"https://localhost:44444"}] + +cd src\ServiceControl +dotnet run +``` + +**Query with a valid token for the primary instance:** + +```cmd +curl -H "Authorization: Bearer %TOKEN%" https://localhost:33333/api/messages | json +``` + +**How to verify the mismatch is detected:** + +1. **Check the Audit instance logs (Terminal 1)** - You should see a 401 Unauthorized response logged, with details about the token validation failure (audience mismatch): + + ```text + warn: Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerHandler + Bearer was not authenticated. Failure message: IDX10214: Audience validation failed... + ``` + +2. **Check the Primary instance logs (Terminal 2)** - You should see the remote marked as temporarily unavailable: + + ```text + warn: ... Remote instance at https://localhost:44444 returned status code Unauthorized + ``` + +3. **Verify the remote status** - Check the remotes endpoint to confirm the Audit instance is marked as unavailable: + + ```cmd + curl -H "Authorization: Bearer %TOKEN%" https://localhost:33333/api/configuration/remotes | json + ``` + + **Expected output:** + + ```json + [ + { + "api_uri": "https://localhost:44444", + "status": "unavailable" + } + ] + ``` + +4. **Confirm direct access fails with the token** - The token is valid for Primary but not for Audit: + + ```cmd + REM Direct to Audit - should fail (wrong audience) + curl -v -H "Authorization: Bearer %TOKEN%" https://localhost:44444/api/messages 2>&1 | findstr /C:"HTTP/" + REM Expected: < HTTP/1.1 401 Unauthorized + ``` + +### Scenario 10: Remote Instance Health Checks with Authentication + +Test that the primary instance can check remote instance health when authentication is enabled. + +> **Note:** The health check queries the `/api` endpoint on remote instances. This endpoint is marked as anonymous and should be accessible without authentication. + +**Start both instances with authentication enabled (same configuration as Scenario 8).** + +**Check the remote instances configuration endpoint:** + +```cmd +curl -H "Authorization: Bearer %TOKEN%" https://localhost:33333/api/configuration/remotes | json +``` + +**Expected output:** + +```json +[ + { + "api_uri": "https://localhost:44444", + "status": "online", + "version": "5.x.x" + } +] +``` + +The health check should succeed because `/api` is an anonymous endpoint. + +### Scenario 11: Platform Connection Details with Authentication + +Test that platform connection details can be retrieved when authentication is enabled on remote instances. + +> **Note:** The primary instance queries `/api/connection` on remote instances to aggregate platform connection details. This endpoint may require authentication. + +**With both instances running (same as Scenario 8):** + +```cmd +curl -H "Authorization: Bearer %TOKEN%" https://localhost:33333/api/connection | json +``` + +**Expected behavior:** + +The platform connection response includes connection details from both the primary and remote instances. + +### Scenario 12: Mixed Authentication Configuration (Primary Only) + +Test behavior when only the primary instance has authentication enabled, but remote instances do not. + +**Terminal 1 - Start ServiceControl.Audit WITHOUT authentication:** + +```cmd +set SERVICECONTROL_AUDIT_AUTHENTICATION_ENABLED= +set SERVICECONTROL_AUDIT_AUTHENTICATION_AUTHORITY= + +cd src\ServiceControl.Audit +dotnet run +``` + +**Terminal 2 - Start ServiceControl (Primary) WITH authentication:** + +```cmd +set SERVICECONTROL_AUTHENTICATION_ENABLED=true +set SERVICECONTROL_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0 +set SERVICECONTROL_AUTHENTICATION_AUDIENCE=api://servicecontrol +set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID={servicepulse-client-id} +set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES=["api://servicecontrol/access_as_user"] +set SERVICECONTROL_REMOTEINSTANCES=[{"api_uri":"https://localhost:44444"}] + +cd src\ServiceControl +dotnet run +``` + +**Query with a valid token:** + +```cmd +curl -H "Authorization: Bearer %TOKEN%" https://localhost:33333/api/messages | json +``` + +**How to verify this mixed configuration works:** + +1. **Verify the Audit instance has no authentication** - Direct requests without a token should succeed: + + ```cmd + REM Direct to Audit without token - should succeed (no auth required) + curl https://localhost:44444/api/messages | json + REM Expected: [] or list of messages + ``` + +2. **Verify the Primary instance requires authentication** - Direct requests without a token should fail: + + ```cmd + REM Direct to Primary without token - should fail + curl -v https://localhost:33333/api/messages 2>&1 | findstr /C:"HTTP/" + REM Expected: < HTTP/1.1 401 Unauthorized + ``` + +3. **Check the Audit instance logs (Terminal 1)** - When queried through the Primary, you should see the request processed. The token is present in the request but ignored since authentication is disabled: + + ```text + info: ... Processed request GET /api/messages + ``` + +4. **Check the Primary instance logs (Terminal 2)** - You should see successful aggregation from the remote: + + ```text + info: ... Successfully retrieved messages from remote https://localhost:44444 + ``` + +5. **Verify aggregation works** - The response from Primary should include data from both instances: + + ```cmd + curl -H "Authorization: Bearer %TOKEN%" https://localhost:33333/api/configuration/remotes | json + ``` + + **Expected output:** + + ```json + [ + { + "api_uri": "https://localhost:44444", + "status": "online", + "version": "5.x.x" + } + ] + ``` + +> **Security Note:** This mixed configuration is not recommended for production. If the primary requires authentication, remote instances should also require authentication to maintain consistent security. + +### Scenario 13: Mixed Authentication Configuration (Remotes Only) + +Test behavior when remote instances have authentication enabled, but the primary does not. + +**Terminal 1 - Start ServiceControl.Audit WITH authentication:** + +```cmd +set SERVICECONTROL_AUDIT_AUTHENTICATION_ENABLED=true +set SERVICECONTROL_AUDIT_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0 +set SERVICECONTROL_AUDIT_AUTHENTICATION_AUDIENCE=api://servicecontrol + +cd src\ServiceControl.Audit +dotnet run +``` + +**Terminal 2 - Start ServiceControl (Primary) WITHOUT authentication:** + +```cmd +set SERVICECONTROL_AUTHENTICATION_ENABLED= +set SERVICECONTROL_AUTHENTICATION_AUTHORITY= +set SERVICECONTROL_REMOTEINSTANCES=[{"api_uri":"https://localhost:44444"}] + +cd src\ServiceControl +dotnet run +``` + +**Query without a token:** + +```cmd +curl https://localhost:33333/api/messages | json +``` + +**How to verify the degraded functionality:** + +1. **Verify the Primary instance has no authentication** - Direct requests without a token should succeed: + + ```cmd + REM Direct to Primary without token - should succeed (no auth required) + curl https://localhost:33333/api | json + REM Expected: API root response + ``` + +2. **Verify the Audit instance requires authentication** - Direct requests without a token should fail: + + ```cmd + REM Direct to Audit without token - should fail + curl -v https://localhost:44444/api/messages 2>&1 | findstr /C:"HTTP/" + REM Expected: < HTTP/1.1 401 Unauthorized + ``` + +3. **Check the Audit instance logs (Terminal 1)** - You should see 401 Unauthorized responses when the Primary tries to query it: + + ```text + warn: Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerHandler + Bearer was not authenticated. Failure message: No token provided... + ``` + +4. **Check the Primary instance logs (Terminal 2)** - You should see the remote marked as temporarily unavailable: + + ```text + warn: ... Remote instance at https://localhost:44444 returned status code Unauthorized + ``` + +5. **Verify the remote is marked unavailable:** + + ```cmd + curl https://localhost:33333/api/configuration/remotes | json + ``` + + **Expected output:** + + ```json + [ + { + "api_uri": "https://localhost:44444", + "status": "unavailable" + } + ] + ``` + +6. **Confirm scatter-gather returns partial results** - The response only contains local Primary data, not aggregated Audit data. Any endpoints or messages stored in the Audit instance will be missing from the response. + +> **Warning:** This configuration results in degraded functionality. Remote instances will be inaccessible for scatter-gather operations. + +### Scenario 14: Expired Token Forwarding + +Test how scatter-gather handles expired tokens being forwarded to remote instances. + +**With both instances running with authentication (same as Scenario 8):** + +**Use an expired token:** + +```cmd +curl -v -H "Authorization: Bearer {expired-token}" https://localhost:33333/api/messages 2>&1 | findstr /C:"HTTP/" +``` + +**Expected output:** + +```text +< HTTP/1.1 401 Unauthorized +``` + +The primary instance rejects the expired token before any remote requests are made. + +## Known Limitations + +### Internal Service-to-Service Communication + +The following internal API calls from the primary instance to remote instances do **not** forward authentication headers: + +| Internal Call | Endpoint | Purpose | +|---------------|----------|---------| +| Health Check | `GET /api` | Verify remote instance availability | +| Configuration | `GET /api/configuration` | Retrieve remote instance configuration | +| Platform Connection | `GET /api/connection` | Aggregate platform connection details | +| License Throughput | `GET /api/endpoints`, `GET /api/endpoints/{name}/audit-count` | Collect audit throughput for licensing | + +**Implications:** + +- These endpoints must be accessible without authentication for multi-instance deployments to work +- The `/api` endpoint is already marked as anonymous on all instances +- The `/api/configuration` endpoint on Audit and Monitoring instances should allow anonymous access for inter-instance communication + +### Same Authentication Configuration Required + +When using scatter-gather with authentication enabled: + +- All instances (Primary, Audit, Monitoring) must use the **same** Authority and Audience +- Client tokens must be valid for all instances +- There is no service-to-service authentication mechanism; client tokens are forwarded directly + +### Token Forwarding Security Considerations + +- Client tokens are forwarded to remote instances in their entirety +- Remote instances see the same token as the primary instance +- Token scope/claims are not modified during forwarding + +## Testing Other Instances + +The scenarios above use ServiceControl (Primary). To test ServiceControl.Audit or ServiceControl.Monitoring: + +1. Use the appropriate environment variable prefix (see Instance Reference above) +2. Use the corresponding project directory and port +3. Note: Audit and Monitoring instances don't require ServicePulse settings + +| Instance | Project Directory | Port | Env Var Prefix | +|----------|-------------------|------|----------------| +| ServiceControl (Primary) | `src\ServiceControl` | 33333 | `SERVICECONTROL_` | +| ServiceControl.Audit | `src\ServiceControl.Audit` | 44444 | `SERVICECONTROL_AUDIT_` | +| ServiceControl.Monitoring | `src\ServiceControl.Monitoring` | 33633 | `MONITORING_` | + +## Cleanup + +After testing, clear the environment variables: + +**Command Prompt (cmd):** + +```cmd +set SERVICECONTROL_AUTHENTICATION_ENABLED= +set SERVICECONTROL_AUTHENTICATION_AUTHORITY= +set SERVICECONTROL_AUTHENTICATION_AUDIENCE= +set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID= +set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES= +set SERVICECONTROL_AUTHENTICATION_VALIDATEISSUER= +set SERVICECONTROL_AUTHENTICATION_VALIDATEAUDIENCE= +set SERVICECONTROL_AUTHENTICATION_VALIDATELIFETIME= +set SERVICECONTROL_AUTHENTICATION_VALIDATEISSUERSIGNINGKEY= +set SERVICECONTROL_AUTHENTICATION_REQUIREHTTPSMETADATA= +``` + +**PowerShell:** + +```powershell +$env:SERVICECONTROL_AUTHENTICATION_ENABLED = $null +$env:SERVICECONTROL_AUTHENTICATION_AUTHORITY = $null +$env:SERVICECONTROL_AUTHENTICATION_AUDIENCE = $null +$env:SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID = $null +$env:SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES = $null +$env:SERVICECONTROL_AUTHENTICATION_VALIDATEISSUER = $null +$env:SERVICECONTROL_AUTHENTICATION_VALIDATEAUDIENCE = $null +$env:SERVICECONTROL_AUTHENTICATION_VALIDATELIFETIME = $null +$env:SERVICECONTROL_AUTHENTICATION_VALIDATEISSUERSIGNINGKEY = $null +$env:SERVICECONTROL_AUTHENTICATION_REQUIREHTTPSMETADATA = $null +``` + +## See Also + +- [Authentication Configuration](authentication.md) - Configuration reference for authentication settings +- [HTTPS Configuration](https-configuration.md) - HTTPS is recommended when authentication is enabled +- [Forwarded Headers Testing](forward-headers-testing.md) - Testing forwarded headers diff --git a/docs/local-forward-headers-testing.md b/docs/forward-headers-testing.md similarity index 99% rename from docs/local-forward-headers-testing.md rename to docs/forward-headers-testing.md index dcf3ef361c..bcbb2d130e 100644 --- a/docs/local-forward-headers-testing.md +++ b/docs/forward-headers-testing.md @@ -836,5 +836,5 @@ dotnet test src/ServiceControl.Monitoring.AcceptanceTests/ServiceControl.Monitor ## See Also - [Hosting Guide](hosting-guide.md) - Configuration reference for forwarded headers -- [Local Reverse Proxy Testing](local-reverseproxy-testing.md) - Testing with a real reverse proxy (NGINX) +- [Reverse Proxy Testing](reverseproxy-testing.md) - Testing with a real reverse proxy (NGINX) - [Testing Architecture](testing-architecture.md) - Overview of testing patterns in this repository diff --git a/docs/forwarded-headers.md b/docs/forwarded-headers.md index 0a8435ef39..3fdb43daa8 100644 --- a/docs/forwarded-headers.md +++ b/docs/forwarded-headers.md @@ -128,5 +128,5 @@ When the proxy is **not** trusted (incorrect `KnownProxies`): ## See Also -- [Local Forwarded Headers Testing](local-forward-headers-testing.md) - Test forwarded headers configuration with curl -- [Local Reverse Proxy Testing](local-reverseproxy-testing.md) - Guide for testing with NGINX reverse proxy locally +- [Forwarded Headers Testing](forward-headers-testing.md) - Test forwarded headers configuration with curl +- [Reverse Proxy Testing](reverseproxy-testing.md) - Guide for testing with NGINX reverse proxy locally diff --git a/docs/https-configuration.md b/docs/https-configuration.md index 7b95283c5e..dee9744847 100644 --- a/docs/https-configuration.md +++ b/docs/https-configuration.md @@ -86,7 +86,7 @@ set SERVICECONTROL_HTTPS_PORT=443 - HSTS is cached by browsers, so test carefully before enabling in production - Start with a short max-age during initial deployment - Consider the impact on subdomains before enabling `includeSubDomains` -- To test HSTS locally, use the [NGINX reverse proxy setup](local-reverseproxy-testing.md) with a custom hostname +- To test HSTS locally, use the [NGINX reverse proxy setup](reverseproxy-testing.md) with a custom hostname ### HTTP to HTTPS Redirect @@ -97,10 +97,10 @@ The `HTTPS_REDIRECTHTTPTOHTTPS` setting is intended for use with a reverse proxy - ServiceControl will redirect HTTP requests to HTTPS based on the `X-Forwarded-Proto` header - **Important:** You must also set `HTTPS_PORT` to specify the HTTPS port for the redirect URL -> **Note:** When running ServiceControl directly without a reverse proxy, the application only listens on a single protocol (HTTP or HTTPS). To test HTTP-to-HTTPS redirection locally, use the [NGINX reverse proxy setup](local-reverseproxy-testing.md). +> **Note:** When running ServiceControl directly without a reverse proxy, the application only listens on a single protocol (HTTP or HTTPS). To test HTTP-to-HTTPS redirection locally, use the [NGINX reverse proxy setup](reverseproxy-testing.md). ## See Also -- [Local HTTPS Testing](local-https-testing.md) - Guide for testing HTTPS locally during development -- [Local Reverse Proxy Testing](local-reverseproxy-testing.md) - Testing with NGINX reverse proxy (HSTS, HTTP to HTTPS redirect) +- [HTTPS Testing](https-testing.md) - Guide for testing HTTPS locally during development +- [Reverse Proxy Testing](reverseproxy-testing.md) - Testing with NGINX reverse proxy (HSTS, HTTP to HTTPS redirect) - [Forwarded Headers Configuration](forwarded-headers.md) - Configure forwarded headers when behind a reverse proxy diff --git a/docs/local-https-testing.md b/docs/https-testing.md similarity index 96% rename from docs/local-https-testing.md rename to docs/https-testing.md index de98c968ca..3216acfbb3 100644 --- a/docs/local-https-testing.md +++ b/docs/https-testing.md @@ -2,7 +2,7 @@ This guide provides scenario-based tests for ServiceControl's direct HTTPS features. Use this to verify Kestrel HTTPS behavior without a reverse proxy. -> **Note:** HTTP to HTTPS redirection (`RedirectHttpToHttps`) is designed for reverse proxy scenarios where the proxy forwards HTTP requests to ServiceControl. When running with direct HTTPS, ServiceControl only binds to a single port (HTTPS). To test HTTP to HTTPS redirection, see [Local Reverse Proxy Testing](local-reverseproxy-testing.md). +> **Note:** HTTP to HTTPS redirection (`RedirectHttpToHttps`) is designed for reverse proxy scenarios where the proxy forwards HTTP requests to ServiceControl. When running with direct HTTPS, ServiceControl only binds to a single port (HTTPS). To test HTTP to HTTPS redirection, see [Reverse Proxy Testing](reverseproxy-testing.md). ## Instance Reference @@ -239,5 +239,5 @@ $env:SERVICECONTROL_FORWARDEDHEADERS_ENABLED = $null ## See Also - [Hosting Guide](hosting-guide.md) - Detailed configuration reference for all deployment scenarios -- [Local Reverse Proxy Testing](local-reverseproxy-testing.md) - Testing with a reverse proxy (NGINX) -- [Local Forwarded Headers Testing](local-forward-headers-testing.md) - Testing forwarded headers without a reverse proxy +- [Reverse Proxy Testing](reverseproxy-testing.md) - Testing with a reverse proxy (NGINX) +- [Forwarded Headers Testing](forward-headers-testing.md) - Testing forwarded headers without a reverse proxy diff --git a/docs/local-authentication-testing.md b/docs/local-authentication-testing.md deleted file mode 100644 index 14d4e23a08..0000000000 --- a/docs/local-authentication-testing.md +++ /dev/null @@ -1,396 +0,0 @@ -# Local Testing Authentication - -This guide explains how to test authentication configuration for ServiceControl instances. This approach uses curl to test authentication enforcement and configuration endpoints. - -## Prerequisites - -- ServiceControl built locally (see main README for build instructions) -- curl (included with Windows 10/11, Git Bash, or WSL) -- (Optional) For formatted JSON output: `npm install -g json` then pipe curl output through `| json` -- (Optional) An OIDC provider for full end-to-end testing (e.g., Microsoft Entra ID, Auth0, Okta) - -## Instance Reference - -| Instance | Project Directory | Default Port | Environment Variable Prefix | -|----------|-------------------|--------------|----------------------------| -| ServiceControl (Primary) | `src\ServiceControl` | 33333 | `SERVICECONTROL_` | -| ServiceControl.Audit | `src\ServiceControl.Audit` | 44444 | `SERVICECONTROL_AUDIT_` | -| ServiceControl.Monitoring | `src\ServiceControl.Monitoring` | 33633 | `MONITORING_` | - -## How Authentication Works - -When authentication is enabled: - -1. All API requests must include a valid JWT bearer token in the `Authorization` header -2. ServiceControl validates the token against the configured OIDC authority -3. Requests without a valid token receive a `401 Unauthorized` response -4. The `/api/authentication/configuration` endpoint returns authentication configuration for clients (like ServicePulse) - -## Configuration Methods - -Settings can be configured via: - -1. **Environment variables** (recommended for testing) - Easy to change between scenarios, no file edits needed -2. **App.config** - Persisted settings, requires app restart after changes - -Both methods work identically. This guide uses environment variables for convenience during iterative testing. - -## Test Scenarios - -The following scenarios use ServiceControl (Primary) as an example. To test other instances, use the appropriate environment variable prefix and port. - -> **Important:** Set environment variables in the same terminal where you run `dotnet run`. Environment variables are scoped to the terminal session. -> -> **Tip:** Check the application startup logs to verify which settings were applied. The authentication configuration is logged at startup. - -### Scenario 1: Authentication Disabled (Default) - -Test the default behavior where authentication is disabled and all requests are allowed. - -**Clear environment variables and start ServiceControl:** - -```cmd -set SERVICECONTROL_AUTHENTICATION_ENABLED= -set SERVICECONTROL_AUTHENTICATION_AUTHORITY= -set SERVICECONTROL_AUTHENTICATION_AUDIENCE= -set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID= -set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES= -set SERVICECONTROL_AUTHENTICATION_REQUIREHTTPSMETADATA= -set SERVICECONTROL_AUTHENTICATION_VALIDATEISSUER= -set SERVICECONTROL_AUTHENTICATION_VALIDATEAUDIENCE= - -cd src\ServiceControl -dotnet run -``` - -**Test with curl (no authorization header):** - -```cmd -curl http://localhost:33333/api | json -``` - -**Expected output:** - -```json -{ - "description": "The management backend for the Particular Service Platform", - ... -} -``` - -Requests succeed without authentication because `Authentication.Enabled` defaults to `false`. - -**Check authentication configuration endpoint:** - -```cmd -curl http://localhost:33333/api/authentication/configuration | json -``` - -**Expected output:** - -```json -{ - "enabled": false -} -``` - -The configuration indicates authentication is disabled. Other fields are omitted when null. - -### Scenario 2: Authentication Enabled (No Token) - -Test that requests without a token are rejected when authentication is enabled. - -> **Note:** This scenario requires a valid OIDC authority URL. For testing authentication enforcement without a real provider, you can use any HTTP URL - the request will fail before token validation because no token is provided. - -**Clear environment variables and start ServiceControl:** - -```cmd -set SERVICECONTROL_AUTHENTICATION_ENABLED=true -set SERVICECONTROL_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/common/v2.0 -set SERVICECONTROL_AUTHENTICATION_AUDIENCE=api://servicecontrol-test -set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID=test-client-id -set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES=["api://servicecontrol-test/.default"] -set SERVICECONTROL_AUTHENTICATION_REQUIREHTTPSMETADATA= -set SERVICECONTROL_AUTHENTICATION_VALIDATEISSUER= -set SERVICECONTROL_AUTHENTICATION_VALIDATEAUDIENCE= - -cd src\ServiceControl -dotnet run -``` - -**Test with curl (no authorization header):** - -```cmd -curl -v http://localhost:33333/api/endpoints 2>&1 | findstr /C:"HTTP/" -``` - -**Expected output:** - -```text -< HTTP/1.1 401 Unauthorized -``` - -Requests without a token are rejected with `401 Unauthorized`. - -> **Note:** The `/api` root endpoint and `/api/authentication/configuration` are marked as anonymous and will return 200 OK even with authentication enabled. Test protected endpoints like `/api/endpoints` to verify authentication enforcement. - -**Check authentication configuration endpoint (no auth required):** - -```cmd -curl http://localhost:33333/api/authentication/configuration | json -``` - -**Expected output:** - -```json -{ - "enabled": true, - "clientId": "test-client-id", - "audience": "api://servicecontrol-test", - "apiScopes": "[\"api://servicecontrol-test/.default\"]" -} -``` - -The authentication configuration endpoint is accessible without authentication and returns the configuration that clients need to authenticate. The `authority` field is omitted when `ServicePulse.Authority` is not explicitly set (it defaults to the main Authority for ServicePulse clients). - -### Scenario 3: Authentication with Invalid Token - -Test that requests with an invalid token are rejected. - -**Start ServiceControl with authentication enabled (same as Scenario 2):** - -```cmd -set SERVICECONTROL_AUTHENTICATION_ENABLED=true -set SERVICECONTROL_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/common/v2.0 -set SERVICECONTROL_AUTHENTICATION_AUDIENCE=api://servicecontrol-test -set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID=test-client-id -set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES=["api://servicecontrol-test/.default"] -set SERVICECONTROL_AUTHENTICATION_REQUIREHTTPSMETADATA= -set SERVICECONTROL_AUTHENTICATION_VALIDATEISSUER= -set SERVICECONTROL_AUTHENTICATION_VALIDATEAUDIENCE= - -cd src\ServiceControl -dotnet run -``` - -**Test with curl (invalid token):** - -```cmd -curl -v -H "Authorization: Bearer invalid-token-here" http://localhost:33333/api/endpoints 2>&1 | findstr /C:"HTTP/" -``` - -**Expected output:** - -```text -< HTTP/1.1 401 Unauthorized -``` - -Invalid tokens are rejected with `401 Unauthorized`. - -### Scenario 4: Anonymous Endpoints - -Test that anonymous endpoints remain accessible when authentication is enabled. - -**With ServiceControl still running from Scenario 2 or 3, test anonymous endpoints:** - -```cmd -curl http://localhost:33333/api | json -``` - -**Expected output:** - -```json -{ - "description": "The management backend for the Particular Service Platform", - ... -} -``` - -```cmd -curl http://localhost:33333/api/authentication/configuration | json -``` - -**Expected output:** - -```json -{ - "enabled": true, - "clientId": "test-client-id", - "audience": "api://servicecontrol-test", - "apiScopes": "[\"api://servicecontrol-test/.default\"]" -} -``` - -The following endpoints are marked as anonymous and accessible without authentication: - -| Endpoint | Purpose | -|----------|---------| -| `/api` | API root/discovery - returns available endpoints | -| `/api/authentication/configuration` | Returns auth config for clients like ServicePulse | - -### Scenario 5: Validation Settings Warnings - -Test that disabling validation settings produces warnings in the logs. - -**Start ServiceControl with relaxed validation:** - -```cmd -set SERVICECONTROL_AUTHENTICATION_ENABLED=true -set SERVICECONTROL_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/common/v2.0 -set SERVICECONTROL_AUTHENTICATION_AUDIENCE=api://servicecontrol-test -set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID=test-client-id -set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES=["api://servicecontrol-test/.default"] -set SERVICECONTROL_AUTHENTICATION_REQUIREHTTPSMETADATA= -set SERVICECONTROL_AUTHENTICATION_VALIDATEISSUER=false -set SERVICECONTROL_AUTHENTICATION_VALIDATEAUDIENCE=false - -cd src\ServiceControl -dotnet run -``` - -**Expected log output:** - -```text -warn: Authentication.ValidateIssuer is set to false. This is not recommended for production environments... -warn: Authentication.ValidateAudience is set to false. This is not recommended for production environments... -``` - -The application warns about insecure validation settings. - -### Scenario 6: Missing Required Settings - -Test that missing required settings prevent startup. - -**Start ServiceControl with missing authority:** - -```cmd -set SERVICECONTROL_AUTHENTICATION_ENABLED=true -set SERVICECONTROL_AUTHENTICATION_AUTHORITY= -set SERVICECONTROL_AUTHENTICATION_AUDIENCE=api://servicecontrol-test -set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID=test-client-id -set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES=["api://servicecontrol-test/.default"] -set SERVICECONTROL_AUTHENTICATION_REQUIREHTTPSMETADATA= -set SERVICECONTROL_AUTHENTICATION_VALIDATEISSUER= -set SERVICECONTROL_AUTHENTICATION_VALIDATEAUDIENCE= - -cd src\ServiceControl -dotnet run -``` - -**Expected behavior:** - -The application fails to start with an error message: - -```text -Authentication.Authority is required when authentication is enabled. Please provide a valid OpenID Connect authority URL... -``` - -### Scenario 7: Authentication with Valid Token (Real Identity Provider) - -Test end-to-end authentication with a valid token from a real OIDC provider. - -> **Prerequisites:** This scenario requires a configured OIDC provider (e.g., Microsoft Entra ID, Auth0, Okta). - -**Microsoft Entra ID Setup (one-time):** - -1. **Create an App Registration** for ServiceControl API: - - Go to Azure Portal > Microsoft Entra ID > App registrations - - Create a new registration (e.g., "ServiceControl API") - - Note the Application (client) ID and Directory (tenant) ID - - Under "Expose an API", add a scope (e.g., `access_as_user`) - -2. **Create an App Registration** for testing (or use ServicePulse's): - - Create another registration for the client application - - Under "API permissions", add permission to your ServiceControl API scope - - Under "Authentication", enable "Allow public client flows" for testing - -**Start ServiceControl with your Entra ID configuration:** - -```cmd -set SERVICECONTROL_AUTHENTICATION_ENABLED=true -set SERVICECONTROL_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0 -set SERVICECONTROL_AUTHENTICATION_AUDIENCE=api://servicecontrol -set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID={servicepulse-client-id} -set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES=["api://servicecontrol/access_as_user"] -set SERVICECONTROL_AUTHENTICATION_REQUIREHTTPSMETADATA= -set SERVICECONTROL_AUTHENTICATION_VALIDATEISSUER= -set SERVICECONTROL_AUTHENTICATION_VALIDATEAUDIENCE= - -cd src\ServiceControl -dotnet run -``` - -**Get a test token using Azure CLI:** - -```cmd -az login -az account get-access-token --resource api://servicecontrol --query accessToken -o tsv -``` - -**Test with the token:** - -```cmd -curl -H "Authorization: Bearer {token}" http://localhost:33333/api/endpoints | json -``` - -**Expected output:** - -```json -[] -``` - -Requests with a valid token are processed successfully. The response will be an empty array if no endpoints are registered, or a list of endpoints if data exists. - -## Testing Other Instances - -The scenarios above use ServiceControl (Primary). To test ServiceControl.Audit or ServiceControl.Monitoring: - -1. Use the appropriate environment variable prefix (see Instance Reference above) -2. Use the corresponding project directory and port -3. Note: Audit and Monitoring instances don't require ServicePulse settings - -| Instance | Project Directory | Port | Env Var Prefix | -|----------|-------------------|------|----------------| -| ServiceControl (Primary) | `src\ServiceControl` | 33333 | `SERVICECONTROL_` | -| ServiceControl.Audit | `src\ServiceControl.Audit` | 44444 | `SERVICECONTROL_AUDIT_` | -| ServiceControl.Monitoring | `src\ServiceControl.Monitoring` | 33633 | `MONITORING_` | - -## Cleanup - -After testing, clear the environment variables: - -**Command Prompt (cmd):** - -```cmd -set SERVICECONTROL_AUTHENTICATION_ENABLED= -set SERVICECONTROL_AUTHENTICATION_AUTHORITY= -set SERVICECONTROL_AUTHENTICATION_AUDIENCE= -set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID= -set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES= -set SERVICECONTROL_AUTHENTICATION_VALIDATEISSUER= -set SERVICECONTROL_AUTHENTICATION_VALIDATEAUDIENCE= -set SERVICECONTROL_AUTHENTICATION_VALIDATELIFETIME= -set SERVICECONTROL_AUTHENTICATION_VALIDATEISSUERSIGNINGKEY= -set SERVICECONTROL_AUTHENTICATION_REQUIREHTTPSMETADATA= -``` - -**PowerShell:** - -```powershell -$env:SERVICECONTROL_AUTHENTICATION_ENABLED = $null -$env:SERVICECONTROL_AUTHENTICATION_AUTHORITY = $null -$env:SERVICECONTROL_AUTHENTICATION_AUDIENCE = $null -$env:SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID = $null -$env:SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES = $null -$env:SERVICECONTROL_AUTHENTICATION_VALIDATEISSUER = $null -$env:SERVICECONTROL_AUTHENTICATION_VALIDATEAUDIENCE = $null -$env:SERVICECONTROL_AUTHENTICATION_VALIDATELIFETIME = $null -$env:SERVICECONTROL_AUTHENTICATION_VALIDATEISSUERSIGNINGKEY = $null -$env:SERVICECONTROL_AUTHENTICATION_REQUIREHTTPSMETADATA = $null -``` - -## See Also - -- [Authentication Configuration](authentication.md) - Configuration reference for authentication settings -- [HTTPS Configuration](https-configuration.md) - HTTPS is recommended when authentication is enabled -- [Local Forwarded Headers Testing](local-forward-headers-testing.md) - Testing forwarded headers diff --git a/docs/local-reverseproxy-testing.md b/docs/reverseproxy-testing.md similarity index 99% rename from docs/local-reverseproxy-testing.md rename to docs/reverseproxy-testing.md index ddf92eddd9..291262d982 100644 --- a/docs/local-reverseproxy-testing.md +++ b/docs/reverseproxy-testing.md @@ -532,4 +532,4 @@ The `/debug/request-info` endpoint is only available when running in Development ## See Also - [Hosting Guide](hosting-guide.md) - Configuration reference for all deployment scenarios -- [Local Forwarded Headers Testing](local-forward-headers-testing.md) - Testing forwarded headers without a reverse proxy +- [Forwarded Headers Testing](forward-headers-testing.md) - Testing forwarded headers without a reverse proxy diff --git a/docs/testing-architecture.md b/docs/testing-architecture.md index b81408ed5f..5f7f894f54 100644 --- a/docs/testing-architecture.md +++ b/docs/testing-architecture.md @@ -349,7 +349,7 @@ public class ForwardedHeadersSettingsTests For middleware configuration like forward headers, end-to-end behavior is best verified through: -1. **Manual testing** with curl - documented in [local-forward-headers-testing.md](local-forward-headers-testing.md) +1. **Manual testing** with curl - documented in [forward-headers-testing.md](forward-headers-testing.md) 2. **Acceptance tests** (optional) - only if automated verification is needed The middleware extension (`UseServiceControlForwardedHeaders`) is configuration wiring that delegates to ASP.NET Core's built-in middleware. Unit testing it would require mocking `WebApplication` and would essentially test ASP.NET Core rather than our code. From 1b8cb6524673adc57b9a5ee2a6e0bbd27cba414d Mon Sep 17 00:00:00 2001 From: Warwick Schroeder Date: Wed, 17 Dec 2025 14:22:45 +0800 Subject: [PATCH 23/24] Clean doc formatting. Update hosting guide. --- docs/authentication-testing.md | 38 +- docs/authentication.md | 92 ++-- docs/forward-headers-testing.md | 88 ++-- docs/forwarded-headers.md | 42 +- docs/hosting-guide.md | 793 +++++++++++++++++++++++--------- docs/https-configuration.md | 84 +++- docs/https-testing.md | 38 +- docs/reverseproxy-testing.md | 42 +- docs/testing-architecture.md | 110 ++--- 9 files changed, 865 insertions(+), 462 deletions(-) diff --git a/docs/authentication-testing.md b/docs/authentication-testing.md index 5c0ae8a44f..37c72c89ac 100644 --- a/docs/authentication-testing.md +++ b/docs/authentication-testing.md @@ -12,11 +12,11 @@ This guide explains how to test authentication configuration for ServiceControl ## Instance Reference -| Instance | Project Directory | Default Port | Environment Variable Prefix | -|----------|-------------------|--------------|----------------------------| -| ServiceControl (Primary) | `src\ServiceControl` | 33333 | `SERVICECONTROL_` | -| ServiceControl.Audit | `src\ServiceControl.Audit` | 44444 | `SERVICECONTROL_AUDIT_` | -| ServiceControl.Monitoring | `src\ServiceControl.Monitoring` | 33633 | `MONITORING_` | +| Instance | Project Directory | Default Port | Environment Variable Prefix | +|---------------------------|---------------------------------|--------------|-----------------------------| +| ServiceControl (Primary) | `src\ServiceControl` | 33333 | `SERVICECONTROL_` | +| ServiceControl.Audit | `src\ServiceControl.Audit` | 44444 | `SERVICECONTROL_AUDIT_` | +| ServiceControl.Monitoring | `src\ServiceControl.Monitoring` | 33633 | `MONITORING_` | ## How Authentication Works @@ -224,9 +224,9 @@ curl http://localhost:33333/api/authentication/configuration | json The following endpoints are marked as anonymous and accessible without authentication: -| Endpoint | Purpose | -|----------|---------| -| `/api` | API root/discovery - returns available endpoints | +| Endpoint | Purpose | +|-------------------------------------|---------------------------------------------------| +| `/api` | API root/discovery - returns available endpoints | | `/api/authentication/configuration` | Returns auth config for clients like ServicePulse | ### Scenario 5: Validation Settings Warnings @@ -747,12 +747,12 @@ The primary instance rejects the expired token before any remote requests are ma The following internal API calls from the primary instance to remote instances do **not** forward authentication headers: -| Internal Call | Endpoint | Purpose | -|---------------|----------|---------| -| Health Check | `GET /api` | Verify remote instance availability | -| Configuration | `GET /api/configuration` | Retrieve remote instance configuration | -| Platform Connection | `GET /api/connection` | Aggregate platform connection details | -| License Throughput | `GET /api/endpoints`, `GET /api/endpoints/{name}/audit-count` | Collect audit throughput for licensing | +| Internal Call | Endpoint | Purpose | +|---------------------|---------------------------------------------------------------|----------------------------------------| +| Health Check | `GET /api` | Verify remote instance availability | +| Configuration | `GET /api/configuration` | Retrieve remote instance configuration | +| Platform Connection | `GET /api/connection` | Aggregate platform connection details | +| License Throughput | `GET /api/endpoints`, `GET /api/endpoints/{name}/audit-count` | Collect audit throughput for licensing | **Implications:** @@ -782,11 +782,11 @@ The scenarios above use ServiceControl (Primary). To test ServiceControl.Audit o 2. Use the corresponding project directory and port 3. Note: Audit and Monitoring instances don't require ServicePulse settings -| Instance | Project Directory | Port | Env Var Prefix | -|----------|-------------------|------|----------------| -| ServiceControl (Primary) | `src\ServiceControl` | 33333 | `SERVICECONTROL_` | -| ServiceControl.Audit | `src\ServiceControl.Audit` | 44444 | `SERVICECONTROL_AUDIT_` | -| ServiceControl.Monitoring | `src\ServiceControl.Monitoring` | 33633 | `MONITORING_` | +| Instance | Project Directory | Port | Env Var Prefix | +|---------------------------|---------------------------------|-------|-------------------------| +| ServiceControl (Primary) | `src\ServiceControl` | 33333 | `SERVICECONTROL_` | +| ServiceControl.Audit | `src\ServiceControl.Audit` | 44444 | `SERVICECONTROL_AUDIT_` | +| ServiceControl.Monitoring | `src\ServiceControl.Monitoring` | 33633 | `MONITORING_` | ## Cleanup diff --git a/docs/authentication.md b/docs/authentication.md index df1a5d5d2c..c129dd9ed0 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -8,47 +8,47 @@ ServiceControl instances can be configured via environment variables or App.conf ### Environment Variables -| Instance | Prefix | -|----------|--------| -| ServiceControl (Primary) | `SERVICECONTROL_` | -| ServiceControl.Audit | `SERVICECONTROL_AUDIT_` | -| ServiceControl.Monitoring | `MONITORING_` | +| Instance | Prefix | +|---------------------------|-------------------------| +| ServiceControl (Primary) | `SERVICECONTROL_` | +| ServiceControl.Audit | `SERVICECONTROL_AUDIT_` | +| ServiceControl.Monitoring | `MONITORING_` | #### Core Settings -| Setting | Default | Description | -|---------|---------|-------------| -| `{PREFIX}AUTHENTICATION_ENABLED` | `false` | Enable JWT authentication | -| `{PREFIX}AUTHENTICATION_AUTHORITY` | (none) | OpenID Connect authority URL (e.g., `https://login.microsoftonline.com/{tenant-id}/v2.0`) | -| `{PREFIX}AUTHENTICATION_AUDIENCE` | (none) | The audience identifier (typically your API identifier or client ID) | +| Setting | Default | Description | +|------------------------------------|---------|-------------------------------------------------------------------------------------------| +| `{PREFIX}AUTHENTICATION_ENABLED` | `false` | Enable JWT authentication | +| `{PREFIX}AUTHENTICATION_AUTHORITY` | (none) | OpenID Connect authority URL (e.g., `https://login.microsoftonline.com/{tenant-id}/v2.0`) | +| `{PREFIX}AUTHENTICATION_AUDIENCE` | (none) | The audience identifier (typically your API identifier or client ID) | #### Validation Settings -| Setting | Default | Description | -|---------|---------|-------------| -| `{PREFIX}AUTHENTICATION_VALIDATEISSUER` | `true` | Validate the token issuer | -| `{PREFIX}AUTHENTICATION_VALIDATEAUDIENCE` | `true` | Validate the token audience | -| `{PREFIX}AUTHENTICATION_VALIDATELIFETIME` | `true` | Validate token expiration | -| `{PREFIX}AUTHENTICATION_VALIDATEISSUERSIGNINGKEY` | `true` | Validate the signing key | -| `{PREFIX}AUTHENTICATION_REQUIREHTTPSMETADATA` | `true` | Require HTTPS for OIDC metadata endpoint | +| Setting | Default | Description | +|---------------------------------------------------|---------|------------------------------------------| +| `{PREFIX}AUTHENTICATION_VALIDATEISSUER` | `true` | Validate the token issuer | +| `{PREFIX}AUTHENTICATION_VALIDATEAUDIENCE` | `true` | Validate the token audience | +| `{PREFIX}AUTHENTICATION_VALIDATELIFETIME` | `true` | Validate token expiration | +| `{PREFIX}AUTHENTICATION_VALIDATEISSUERSIGNINGKEY` | `true` | Validate the signing key | +| `{PREFIX}AUTHENTICATION_REQUIREHTTPSMETADATA` | `true` | Require HTTPS for OIDC metadata endpoint | #### ServicePulse Settings (Primary Instance Only) These settings are required on the primary ServiceControl instance to provide authentication configuration to ServicePulse clients. -| Setting | Default | Description | -|---------|---------|-------------| -| `{PREFIX}AUTHENTICATION_SERVICEPULSE_CLIENTID` | (none) | Client ID for ServicePulse application | -| `{PREFIX}AUTHENTICATION_SERVICEPULSE_AUTHORITY` | (none) | Authority URL for ServicePulse (defaults to main Authority if not set) | -| `{PREFIX}AUTHENTICATION_SERVICEPULSE_APISCOPES` | (none) | JSON array of API scopes (e.g., `["api://servicecontrol/access_as_user"]`) | +| Setting | Default | Description | +|-------------------------------------------------|---------|--------------------------------------------------------------------------------------------------------| +| `{PREFIX}AUTHENTICATION_SERVICEPULSE_CLIENTID` | (none) | Client ID for ServicePulse application | +| `{PREFIX}AUTHENTICATION_SERVICEPULSE_AUTHORITY` | (none) | Authority URL for ServicePulse (defaults to main Authority if not set) | +| `{PREFIX}AUTHENTICATION_SERVICEPULSE_APISCOPES` | (none) | JSON array of API scopes for ServicePulse to request (e.g., `["api://servicecontrol/access_as_user"]`) | ### App.config -| Instance | Key Prefix | -|----------|------------| -| ServiceControl (Primary) | `ServiceControl/` | -| ServiceControl.Audit | `ServiceControl.Audit/` | -| ServiceControl.Monitoring | `Monitoring/` | +| Instance | Key Prefix | +|---------------------------|-------------------------| +| ServiceControl (Primary) | `ServiceControl/` | +| ServiceControl.Audit | `ServiceControl.Audit/` | +| ServiceControl.Monitoring | `Monitoring/` | ```xml @@ -66,7 +66,7 @@ These settings are required on the primary ServiceControl instance to provide au - + ``` @@ -85,7 +85,7 @@ set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES=["api://servicecontrol/ ### Docker Example ```cmd -docker run -p 33333:33333 -e SERVICECONTROL_AUTHENTICATION_ENABLED=true -e SERVICECONTROL_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0 -e SERVICECONTROL_AUTHENTICATION_AUDIENCE=api://servicecontrol -e SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID={servicepulse-client-id} -e "SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES=[\"api://servicecontrol/.default\"]" particular/servicecontrol:latest +docker run -p 33333:33333 -e SERVICECONTROL_AUTHENTICATION_ENABLED=true -e SERVICECONTROL_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0 -e SERVICECONTROL_AUTHENTICATION_AUDIENCE=api://servicecontrol -e SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID={servicepulse-client-id} -e SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES=["api://servicecontrol/access_as_user"] particular/servicecontrol:latest ``` ### Audit and Monitoring Instances @@ -117,10 +117,10 @@ When authentication is enabled: The following endpoints are accessible without authentication, even when authentication is enabled: -| Endpoint | Purpose | -|----------|---------| -| `/api` | API root/discovery - returns available endpoints and API information | -| `/api/authentication/configuration` | Returns authentication configuration for clients like ServicePulse | +| Endpoint | Purpose | +|-------------------------------------|----------------------------------------------------------------------| +| `/api` | API root/discovery - returns available endpoints and API information | +| `/api/authentication/configuration` | Returns authentication configuration for clients like ServicePulse | These endpoints must remain accessible so clients can discover API capabilities and obtain the authentication configuration needed to acquire tokens. @@ -157,13 +157,13 @@ When authentication is enabled, HTTPS is strongly recommended for production dep The default validation settings are recommended for production: -| Setting | Recommendation | -|---------|----------------| -| `ValidateIssuer` | `true` - Prevents tokens from untrusted issuers | -| `ValidateAudience` | `true` - Prevents tokens intended for other applications | -| `ValidateLifetime` | `true` - Prevents expired tokens | -| `ValidateIssuerSigningKey` | `true` - Ensures token signature is valid | -| `RequireHttpsMetadata` | `true` - Ensures OIDC metadata is fetched securely | +| Setting | Recommendation | +|----------------------------|----------------------------------------------------------| +| `ValidateIssuer` | `true` - Prevents tokens from untrusted issuers | +| `ValidateAudience` | `true` - Prevents tokens intended for other applications | +| `ValidateLifetime` | `true` - Prevents expired tokens | +| `ValidateIssuerSigningKey` | `true` - Ensures token signature is valid | +| `RequireHttpsMetadata` | `true` - Ensures OIDC metadata is fetched securely | ### Development Settings @@ -181,7 +181,7 @@ When `RequireHttpsMetadata` is `true` (the default), the Authority URL must use ## Configuring Identity Providers -### Microsoft Entra ID (Azure AD) +### Microsoft Entra ID Setup 1. Register an application in Azure AD for ServiceControl API 2. Register a separate application for ServicePulse (SPA) @@ -200,11 +200,11 @@ ServiceControl works with any OIDC-compliant provider. Configure: The primary ServiceControl instance requires ServicePulse settings because it serves the `/api/auth/config` endpoint that ServicePulse uses to configure its authentication. Audit and Monitoring instances only need the core authentication settings. -| Instance | Requires ServicePulse Settings | -|----------|-------------------------------| -| ServiceControl (Primary) | Yes | -| ServiceControl.Audit | No | -| ServiceControl.Monitoring | No | +| Instance | Requires ServicePulse Settings | +|---------------------------|--------------------------------| +| ServiceControl (Primary) | Yes | +| ServiceControl.Audit | No | +| ServiceControl.Monitoring | No | ## See Also diff --git a/docs/forward-headers-testing.md b/docs/forward-headers-testing.md index bcbb2d130e..d2e8521275 100644 --- a/docs/forward-headers-testing.md +++ b/docs/forward-headers-testing.md @@ -11,11 +11,11 @@ This guide explains how to test forwarded headers configuration for ServiceContr ## Instance Reference -| Instance | Project Directory | Default Port | Environment Variable Prefix | -|----------|-------------------|--------------|----------------------------| -| ServiceControl (Primary) | `src\ServiceControl` | 33333 | `SERVICECONTROL_` | -| ServiceControl.Audit | `src\ServiceControl.Audit` | 44444 | `SERVICECONTROL_AUDIT_` | -| ServiceControl.Monitoring | `src\ServiceControl.Monitoring` | 33633 | `MONITORING_` | +| Instance | Project Directory | Default Port | Environment Variable Prefix | +|---------------------------|---------------------------------|--------------|-----------------------------| +| ServiceControl (Primary) | `src\ServiceControl` | 33333 | `SERVICECONTROL_` | +| ServiceControl.Audit | `src\ServiceControl.Audit` | 44444 | `SERVICECONTROL_AUDIT_` | +| ServiceControl.Monitoring | `src\ServiceControl.Monitoring` | 33633 | `MONITORING_` | > **Note:** Environment variables must include the instance prefix (e.g., `SERVICECONTROL_FORWARDEDHEADERS_ENABLED` for the primary instance). @@ -692,18 +692,18 @@ The `/debug/request-info` endpoint is only available in Development environment. } ``` -| Section | Field | Description | -|---------|-------|-------------| -| `processed` | `scheme` | The request scheme after forwarded headers processing | -| `processed` | `host` | The request host after forwarded headers processing | -| `processed` | `remoteIpAddress` | The client IP after forwarded headers processing | -| `rawHeaders` | `xForwardedFor` | Raw `X-Forwarded-For` header (empty if consumed by middleware) | -| `rawHeaders` | `xForwardedProto` | Raw `X-Forwarded-Proto` header (empty if consumed by middleware) | -| `rawHeaders` | `xForwardedHost` | Raw `X-Forwarded-Host` header (empty if consumed by middleware) | -| `configuration` | `enabled` | Whether forwarded headers middleware is enabled | -| `configuration` | `trustAllProxies` | Whether all proxies are trusted (security warning if true) | -| `configuration` | `knownProxies` | List of trusted proxy IP addresses | -| `configuration` | `knownNetworks` | List of trusted CIDR network ranges | +| Section | Field | Description | +|-----------------|-------------------|------------------------------------------------------------------| +| `processed` | `scheme` | The request scheme after forwarded headers processing | +| `processed` | `host` | The request host after forwarded headers processing | +| `processed` | `remoteIpAddress` | The client IP after forwarded headers processing | +| `rawHeaders` | `xForwardedFor` | Raw `X-Forwarded-For` header (empty if consumed by middleware) | +| `rawHeaders` | `xForwardedProto` | Raw `X-Forwarded-Proto` header (empty if consumed by middleware) | +| `rawHeaders` | `xForwardedHost` | Raw `X-Forwarded-Host` header (empty if consumed by middleware) | +| `configuration` | `enabled` | Whether forwarded headers middleware is enabled | +| `configuration` | `trustAllProxies` | Whether all proxies are trusted (security warning if true) | +| `configuration` | `knownProxies` | List of trusted proxy IP addresses | +| `configuration` | `knownNetworks` | List of trusted CIDR network ranges | ### Key Diagnostic Questions @@ -775,20 +775,20 @@ dotnet test src/ServiceControl.UnitTests/ServiceControl.UnitTests.csproj --filte ### What the Tests Cover -| Test | Purpose | -|------|---------| -| `Should_parse_known_proxies_from_comma_separated_list` | Verifies parsing of multiple proxy IPs | -| `Should_parse_known_proxies_to_ip_addresses` | Verifies `KnownProxies` property returns valid `IPAddress` objects | -| `Should_ignore_invalid_ip_addresses` | Verifies invalid IPs are filtered out gracefully | -| `Should_parse_known_networks_from_comma_separated_cidr` | Verifies CIDR notation parsing | -| `Should_ignore_invalid_network_cidr` | Verifies invalid CIDR entries are filtered | -| `Should_disable_trust_all_proxies_when_known_proxies_configured` | Verifies auto-disable behavior | -| `Should_disable_trust_all_proxies_when_known_networks_configured` | Verifies auto-disable behavior | -| `Should_default_to_enabled` | Verifies default value | -| `Should_default_to_trust_all_proxies` | Verifies default value | -| `Should_respect_explicit_disabled_setting` | Verifies explicit configuration | -| `Should_handle_semicolon_separator_in_proxies` | Tests alternate separator | -| `Should_trim_whitespace_from_proxy_entries` | Tests whitespace handling | +| Test | Purpose | +|-------------------------------------------------------------------|--------------------------------------------------------------------| +| `Should_parse_known_proxies_from_comma_separated_list` | Verifies parsing of multiple proxy IPs | +| `Should_parse_known_proxies_to_ip_addresses` | Verifies `KnownProxies` property returns valid `IPAddress` objects | +| `Should_ignore_invalid_ip_addresses` | Verifies invalid IPs are filtered out gracefully | +| `Should_parse_known_networks_from_comma_separated_cidr` | Verifies CIDR notation parsing | +| `Should_ignore_invalid_network_cidr` | Verifies invalid CIDR entries are filtered | +| `Should_disable_trust_all_proxies_when_known_proxies_configured` | Verifies auto-disable behavior | +| `Should_disable_trust_all_proxies_when_known_networks_configured` | Verifies auto-disable behavior | +| `Should_default_to_enabled` | Verifies default value | +| `Should_default_to_trust_all_proxies` | Verifies default value | +| `Should_respect_explicit_disabled_setting` | Verifies explicit configuration | +| `Should_handle_semicolon_separator_in_proxies` | Tests alternate separator | +| `Should_trim_whitespace_from_proxy_entries` | Tests whitespace handling | ## Acceptance Tests @@ -817,19 +817,19 @@ dotnet test src/ServiceControl.Monitoring.AcceptanceTests/ServiceControl.Monitor ### Scenarios Covered -| Scenario | Test | -|----------|------| -| 0 | `When_request_has_no_forwarded_headers` | -| 1/2 | `When_forwarded_headers_are_sent` | -| 3 | `When_known_proxies_are_configured` | -| 4 | `When_known_networks_are_configured` | -| 5 | `When_unknown_proxy_sends_headers` | -| 6 | `When_unknown_network_sends_headers` | -| 7 | `When_forwarded_headers_are_disabled` | -| 8 | `When_proxy_chain_headers_are_sent` | -| 9 | `When_proxy_chain_headers_are_sent_with_known_proxies` | -| 10 | `When_combined_proxies_and_networks_are_configured` | -| 11 | `When_only_proto_header_is_sent` | +| Scenario | Test | +|----------|--------------------------------------------------------| +| 0 | `When_request_has_no_forwarded_headers` | +| 1/2 | `When_forwarded_headers_are_sent` | +| 3 | `When_known_proxies_are_configured` | +| 4 | `When_known_networks_are_configured` | +| 5 | `When_unknown_proxy_sends_headers` | +| 6 | `When_unknown_network_sends_headers` | +| 7 | `When_forwarded_headers_are_disabled` | +| 8 | `When_proxy_chain_headers_are_sent` | +| 9 | `When_proxy_chain_headers_are_sent_with_known_proxies` | +| 10 | `When_combined_proxies_and_networks_are_configured` | +| 11 | `When_only_proto_header_is_sent` | > **Note:** Scenario 12 (IPv4/IPv6 Mismatch) is not covered by acceptance tests because the test server's IP address (IPv4 vs IPv6) cannot be controlled reliably. The "untrusted proxy" behavior is already validated by Scenarios 5 and 6. diff --git a/docs/forwarded-headers.md b/docs/forwarded-headers.md index 3fdb43daa8..7c87f94fd0 100644 --- a/docs/forwarded-headers.md +++ b/docs/forwarded-headers.md @@ -2,32 +2,34 @@ When ServiceControl instances are deployed behind a reverse proxy (like NGINX, Traefik, or a cloud load balancer) that terminates SSL/TLS, you need to configure forwarded headers so ServiceControl correctly understands the original client request. +> **⚠️ Security Warning:** The default configuration (`TrustAllProxies = true`) is suitable for development and trusted container environments only. For production deployments accessible from untrusted networks, always configure `KnownProxies` or `KnownNetworks` to restrict which sources can set forwarded headers. Failing to do so can allow attackers to spoof client IP addresses. + ## Configuration ServiceControl instances can be configured via environment variables or App.config. Each instance type uses a different prefix. ### Environment Variables -| Instance | Prefix | -|----------|--------| -| ServiceControl (Primary) | `SERVICECONTROL_` | -| ServiceControl.Audit | `SERVICECONTROL_AUDIT_` | -| ServiceControl.Monitoring | `MONITORING_` | +| Instance | Prefix | +|---------------------------|-------------------------| +| ServiceControl (Primary) | `SERVICECONTROL_` | +| ServiceControl.Audit | `SERVICECONTROL_AUDIT_` | +| ServiceControl.Monitoring | `MONITORING_` | -| Setting | Default | Description | -|---------|---------|-------------| -| `{PREFIX}FORWARDEDHEADERS_ENABLED` | `true` | Enable forwarded headers processing | -| `{PREFIX}FORWARDEDHEADERS_TRUSTALLPROXIES` | `true` | Trust all proxies (auto-disabled if known proxies/networks set) | -| `{PREFIX}FORWARDEDHEADERS_KNOWNPROXIES` | (none) | Comma-separated IP addresses of trusted proxies | -| `{PREFIX}FORWARDEDHEADERS_KNOWNNETWORKS` | (none) | Comma-separated CIDR networks (e.g., `10.0.0.0/8,172.16.0.0/12`) | +| Setting | Default | Description | +|--------------------------------------------|---------|------------------------------------------------------------------| +| `{PREFIX}FORWARDEDHEADERS_ENABLED` | `true` | Enable forwarded headers processing | +| `{PREFIX}FORWARDEDHEADERS_TRUSTALLPROXIES` | `true` | Trust all proxies (auto-disabled if known proxies/networks set) | +| `{PREFIX}FORWARDEDHEADERS_KNOWNPROXIES` | (none) | Comma-separated IP addresses of trusted proxies | +| `{PREFIX}FORWARDEDHEADERS_KNOWNNETWORKS` | (none) | Comma-separated CIDR networks (e.g., `10.0.0.0/8,172.16.0.0/12`) | ### App.config -| Instance | Key Prefix | -|----------|------------| -| ServiceControl (Primary) | `ServiceControl/` | -| ServiceControl.Audit | `ServiceControl.Audit/` | -| ServiceControl.Monitoring | `Monitoring/` | +| Instance | Key Prefix | +|---------------------------|-------------------------| +| ServiceControl (Primary) | `ServiceControl/` | +| ServiceControl.Audit | `ServiceControl.Audit/` | +| ServiceControl.Monitoring | `Monitoring/` | ```xml @@ -97,10 +99,10 @@ Or in App.config: When processing `X-Forwarded-For` headers with multiple IPs (proxy chains), the behavior depends on trust configuration: -| Configuration | ForwardLimit | Behavior | -|---------------|--------------|----------| -| `TrustAllProxies = true` | `null` (no limit) | Processes all IPs, returns original client IP | -| `TrustAllProxies = false` | `1` (default) | Processes only the last proxy IP | +| Configuration | ForwardLimit | Behavior | +|---------------------------|-------------------|-----------------------------------------------| +| `TrustAllProxies = true` | `null` (no limit) | Processes all IPs, returns original client IP | +| `TrustAllProxies = false` | `1` (default) | Processes only the last proxy IP | For example, with `X-Forwarded-For: 203.0.113.50, 10.0.0.1, 192.168.1.1`: diff --git a/docs/hosting-guide.md b/docs/hosting-guide.md index 0f620d968b..726ab6a4e4 100644 --- a/docs/hosting-guide.md +++ b/docs/hosting-guide.md @@ -1,286 +1,559 @@ -# ServiceControl Hosting and Security Guide +# ServiceControl Production Hosting Guide -This guide covers all hosting and security options available for ServiceControl, ServiceControl.Audit, and ServiceControl.Monitoring instances. +This guide covers hosting and security configuration for ServiceControl in production environments. All scenarios assume HTTPS and authentication are required. -## Configuration Settings by Instance +--- -Each ServiceControl instance uses a different configuration prefix: +## Configuration Basics -| Instance | Configuration Prefix | Default Port | -|----------|---------------------|--------------| -| ServiceControl (Primary) | `ServiceControl/` | 33333 | -| ServiceControl.Audit | `ServiceControl.Audit/` | 44444 | -| ServiceControl.Monitoring | `Monitoring/` | 33633 | +### Instance Types and Prefixes -Settings can be configured via: +ServiceControl consists of three deployable instances: -- App.config / Web.config files -- Windows Registry (legacy) +| Instance | Purpose | Config Prefix | Env Var Prefix | Default Port | +|---------------------------|--------------------------------------|-------------------------|-------------------------|--------------| +| ServiceControl (Primary) | Error handling, retries, heartbeats | `ServiceControl/` | `SERVICECONTROL_` | 33333 | +| ServiceControl.Audit | Audit message ingestion and querying | `ServiceControl.Audit/` | `SERVICECONTROL_AUDIT_` | 44444 | +| ServiceControl.Monitoring | Endpoint performance monitoring | `Monitoring/` | `MONITORING_` | 33633 | ---- +### Configuration Methods -## Hosting Model +Settings can be configured via: -ServiceControl runs as a standalone Windows service with Kestrel as the built-in web server. It does not support being hosted inside IIS (in-process hosting). +- **App.config** - Recommended for Windows service deployments +- **Environment variables** - Recommended for containers -If you place IIS, nginx, or another web server in front of ServiceControl, it acts as a **reverse proxy** forwarding requests to Kestrel. +### Host and Port Configuration ---- +Configure the hostname and port that each instance listens on: -## Deployment Scenarios +**App.config:** -### Scenario 1: Default Configuration +```xml + + + + + -The default configuration with no additional setup required. Backwards compatible with existing deployments. + + + + + -**Security Features:** + + + + + +``` -| Feature | Status | -|---------|--------| -| JWT Authentication | ❌ Disabled | -| Kestrel HTTPS | ❌ Disabled | -| HTTPS Redirection | ❌ Disabled | -| HSTS | ❌ Disabled | -| Restricted CORS Origins | ❌ Disabled (any origin) | -| Forwarded Headers | ✅ Enabled (trusts all) | -| Restricted Proxy Trust | ❌ Disabled | +**Environment variables:** -```xml - - - - - +```bash +# ServiceControl Primary +SERVICECONTROL_HOSTNAME=localhost +SERVICECONTROL_PORT=33333 + +# ServiceControl.Audit +SERVICECONTROL_AUDIT_HOSTNAME=localhost +SERVICECONTROL_AUDIT_PORT=44444 + +# ServiceControl.Monitoring +MONITORING_HOSTNAME=localhost +MONITORING_PORT=33633 ``` -Or explicitly: +> **Note:** Use `localhost` or `+` (all interfaces) for the hostname. When behind a reverse proxy, use `localhost` and configure the proxy to forward to the appropriate port. + +### Remote Instances Configuration + +The Primary instance must be configured to communicate with Audit instances for scatter-gather operations (aggregating data across instances): + +**App.config:** ```xml + - - - - - + ``` +**Environment variables:** + +```bash +# Single Audit instance +SERVICECONTROL_REMOTEINSTANCES=[{"api_uri":"https://servicecontrol-audit:44444/api"}] +``` + +For multiple Audit instances: + +**App.config:** + +```xml + +``` + +**Environment variables:** + +```bash +SERVICECONTROL_REMOTEINSTANCES=[{"api_uri":"https://servicecontrol-audit1:44444/api"},{"api_uri":"https://servicecontrol-audit2:44444/api"}] +``` + +> **Important:** When authentication is enabled, all instances (Primary, Audit, Monitoring) must use the **same** Identity Provider (IdP) Authority and Audience settings. Client tokens are forwarded to remote instances during scatter-gather operations. + --- -### Scenario 2: Reverse Proxy with SSL Termination +## Production Deployment Scenarios + +### Scenario 1: Reverse Proxy with Authentication + +A reverse proxy (NGINX, IIS, cloud load balancer) handles SSL/TLS termination, and ServiceControl validates JWT tokens. + +**Architecture:** -ServiceControl behind a reverse proxy (nginx, IIS, cloud load balancer) that handles SSL/TLS termination. +```text +Client → HTTPS → Reverse Proxy → HTTP → ServiceControl + (SSL termination) (JWT validation) +``` **Security Features:** -| Feature | Status | -|---------|--------| -| JWT Authentication | ❌ Disabled | -| Kestrel HTTPS | ❌ Disabled (handled by proxy) | -| HTTPS Redirection | ❌ Disabled (handled by proxy) | -| HSTS | ❌ Disabled (handled by proxy) | -| Restricted CORS Origins | ✅ Enabled | -| Forwarded Headers | ✅ Enabled | -| Restricted Proxy Trust | ✅ Enabled | +| Feature | Status | +|-------------------------|---------------------------------| +| JWT Authentication | ✅ Enabled | +| Kestrel HTTPS | ❌ Disabled (handled by proxy) | +| HTTPS Redirection | ✅ Enabled (optional) | +| HSTS | ❌ Disabled (configure at proxy) | +| Restricted CORS Origins | ✅ Enabled | +| Forwarded Headers | ✅ Enabled | +| Restricted Proxy Trust | ✅ Enabled | + +> **Note:** HTTPS redirection is optional in this scenario. The reverse proxy typically handles HTTP to HTTPS redirection at its layer. However, enabling it at ServiceControl provides defense-in-depth - if an HTTP request somehow bypasses the proxy and reaches ServiceControl directly, it will be redirected to the HTTPS URL. This requires configuring `Https.Port` to specify the external HTTPS port used by the proxy. + +#### ServiceControl Primary Configuration + +**App.config:** ```xml - + + + + + + + + + + + + + + + + + - - - + + + + + + - + ``` ---- +**Environment variables:** -### Scenario 3: Direct HTTPS (No Reverse Proxy) +```bash +# Host and Port +SERVICECONTROL_HOSTNAME=localhost +SERVICECONTROL_PORT=33333 -Kestrel handles TLS directly without a reverse proxy. +# Remote Audit Instance(s) +SERVICECONTROL_REMOTEINSTANCES=[{"api_uri":"https://servicecontrol-audit/api"}] -**Security Features:** +# Authentication +SERVICECONTROL_AUTHENTICATION_ENABLED=true +SERVICECONTROL_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0 +SERVICECONTROL_AUTHENTICATION_AUDIENCE=api://servicecontrol + +# ServicePulse client configuration (Primary instance only) +SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID={servicepulse-client-id} +SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES=["api://servicecontrol/access_as_user"] + +# Forwarded headers - trust only your reverse proxy +SERVICECONTROL_FORWARDEDHEADERS_ENABLED=true +SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES=false +SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES=10.0.0.5 +# Or use CIDR notation: +# SERVICECONTROL_FORWARDEDHEADERS_KNOWNNETWORKS=10.0.0.0/24 + +# HTTP to HTTPS redirect (optional) +SERVICECONTROL_HTTPS_REDIRECTHTTPTOHTTPS=true +SERVICECONTROL_HTTPS_PORT=443 + +# Restrict CORS +SERVICECONTROL_CORS_ALLOWEDORIGINS=https://servicepulse +``` -| Feature | Status | -|---------|--------| -| JWT Authentication | ❌ Disabled | -| Kestrel HTTPS | ✅ Enabled | -| HTTPS Redirection | ✅ Enabled | -| HSTS | ✅ Enabled | -| Restricted CORS Origins | ✅ Enabled | -| Forwarded Headers | ❌ Disabled (no proxy) | -| Restricted Proxy Trust | N/A | +#### ServiceControl.Audit Configuration + +**App.config:** ```xml - - - - + + + - - - + + + + - - + + + + - - - - + ``` ---- +**Environment variables:** -### Scenario 4: Reverse Proxy with Authentication +```bash +# Host and Port +SERVICECONTROL_AUDIT_HOSTNAME=localhost +SERVICECONTROL_AUDIT_PORT=44444 -Reverse proxy with SSL termination and JWT authentication via an identity provider (Azure AD, Okta, Auth0, Keycloak, etc.). +# Authentication (same Authority and Audience as Primary) +SERVICECONTROL_AUDIT_AUTHENTICATION_ENABLED=true +SERVICECONTROL_AUDIT_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0 +SERVICECONTROL_AUDIT_AUTHENTICATION_AUDIENCE=api://servicecontrol -**Security Features:** +# Forwarded headers +SERVICECONTROL_AUDIT_FORWARDEDHEADERS_ENABLED=true +SERVICECONTROL_AUDIT_FORWARDEDHEADERS_TRUSTALLPROXIES=false +SERVICECONTROL_AUDIT_FORWARDEDHEADERS_KNOWNPROXIES=10.0.0.5 + +# Restrict CORS +SERVICECONTROL_AUDIT_CORS_ALLOWEDORIGINS=https://servicepulse +``` + +#### ServiceControl.Monitoring Configuration -| Feature | Status | -|---------|--------| -| JWT Authentication | ✅ Enabled | -| Kestrel HTTPS | ❌ Disabled (handled by proxy) | -| HTTPS Redirection | ❌ Disabled (handled by proxy) | -| HSTS | ❌ Disabled (handled by proxy) | -| Restricted CORS Origins | ✅ Enabled | -| Forwarded Headers | ✅ Enabled | -| Restricted Proxy Trust | ✅ Enabled | +**App.config:** ```xml - - - - + + + - - - - - - + + + + - - - - - - - - - + + + + - + ``` -> **Note:** The token validation settings (`ValidateIssuer`, `ValidateAudience`, `ValidateLifetime`, `ValidateIssuerSigningKey`, `RequireHttpsMetadata`) all default to `true`. +**Environment variables:** + +```bash +# Host and Port +MONITORING_HOSTNAME=localhost +MONITORING_PORT=33633 + +# Authentication (same Authority and Audience as Primary) +MONITORING_AUTHENTICATION_ENABLED=true +MONITORING_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0 +MONITORING_AUTHENTICATION_AUDIENCE=api://servicecontrol + +# Forwarded headers +MONITORING_FORWARDEDHEADERS_ENABLED=true +MONITORING_FORWARDEDHEADERS_TRUSTALLPROXIES=false +MONITORING_FORWARDEDHEADERS_KNOWNPROXIES=10.0.0.5 + +# Restrict CORS +MONITORING_CORS_ALLOWEDORIGINS=https://servicepulse +``` --- -### Scenario 5: Direct HTTPS with Authentication +### Scenario 2: Direct HTTPS with Authentication -Kestrel handles TLS directly with JWT authentication. No reverse proxy. +Kestrel handles TLS directly without a reverse proxy. Suitable for simpler deployments or when a reverse proxy is not available. + +**Architecture:** + +```text +Client → HTTPS → ServiceControl (Kestrel) + (TLS + JWT validation) +``` **Security Features:** -| Feature | Status | -|---------|--------| -| JWT Authentication | ✅ Enabled | -| Kestrel HTTPS | ✅ Enabled | -| HTTPS Redirection | ✅ Enabled | -| HSTS | ✅ Enabled | -| Restricted CORS Origins | ✅ Enabled | -| Forwarded Headers | ❌ Disabled (no proxy) | -| Restricted Proxy Trust | N/A | +| Feature | Status | +|-------------------------|-----------------------| +| JWT Authentication | ✅ Enabled | +| Kestrel HTTPS | ✅ Enabled | +| HSTS | ✅ Enabled | +| Restricted CORS Origins | ✅ Enabled | +| Forwarded Headers | ❌ Disabled (no proxy) | +| Restricted Proxy Trust | N/A | + +> **Note:** HTTPS redirection is not configured in this scenario because clients connect directly over HTTPS. There is no HTTP endpoint exposed that would need to redirect. HTTPS redirection is only useful when a reverse proxy handles SSL termination and ServiceControl needs to redirect HTTP requests to the proxy's HTTPS endpoint. + +#### Primary Instance Configuration (Direct HTTPS) + +**App.config:** ```xml + + + + + + + - - - - - - - - - - + + + - + ``` +**Environment variables:** + +```bash +# Host and Port +SERVICECONTROL_HOSTNAME=servicecontrol +SERVICECONTROL_PORT=33333 + +# Remote Audit Instance(s) +SERVICECONTROL_REMOTEINSTANCES=[{"api_uri":"https://servicecontrol-audit:44444/api"}] + +# Kestrel HTTPS +SERVICECONTROL_HTTPS_ENABLED=true +SERVICECONTROL_HTTPS_CERTIFICATEPATH=/certs/servicecontrol.pfx +SERVICECONTROL_HTTPS_CERTIFICATEPASSWORD=your-certificate-password +SERVICECONTROL_HTTPS_ENABLEHSTS=true +SERVICECONTROL_HTTPS_HSTSMAXAGESECONDS=31536000 + +# Authentication +SERVICECONTROL_AUTHENTICATION_ENABLED=true +SERVICECONTROL_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0 +SERVICECONTROL_AUTHENTICATION_AUDIENCE=api://servicecontrol + +# ServicePulse client configuration +SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID={servicepulse-client-id} +SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES=["api://servicecontrol/access_as_user"] + +# Restrict CORS +SERVICECONTROL_CORS_ALLOWEDORIGINS=https://servicepulse + +# No forwarded headers (no proxy) +SERVICECONTROL_FORWARDEDHEADERS_ENABLED=false +``` + +#### Audit Instance Configuration (Direct HTTPS) + +**App.config:** + +```xml + + + + + + + + + + + + + + + + + + + + + + +``` + +**Environment variables:** + +```bash +# Host and Port +SERVICECONTROL_AUDIT_HOSTNAME=servicecontrol-audit +SERVICECONTROL_AUDIT_PORT=44444 + +# Kestrel HTTPS +SERVICECONTROL_AUDIT_HTTPS_ENABLED=true +SERVICECONTROL_AUDIT_HTTPS_CERTIFICATEPATH=/certs/servicecontrol-audit.pfx +SERVICECONTROL_AUDIT_HTTPS_CERTIFICATEPASSWORD=your-certificate-password +SERVICECONTROL_AUDIT_HTTPS_ENABLEHSTS=true + +# Authentication +SERVICECONTROL_AUDIT_AUTHENTICATION_ENABLED=true +SERVICECONTROL_AUDIT_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0 +SERVICECONTROL_AUDIT_AUTHENTICATION_AUDIENCE=api://servicecontrol + +# Restrict CORS +SERVICECONTROL_AUDIT_CORS_ALLOWEDORIGINS=https://servicepulse + +# No forwarded headers +SERVICECONTROL_AUDIT_FORWARDEDHEADERS_ENABLED=false +``` + +#### Monitoring Instance Configuration (Direct HTTPS) + +**App.config:** + +```xml + + + + + + + + + + + + + + + + + + + + + + +``` + +**Environment variables:** + +```bash +# Host and Port +MONITORING_HOSTNAME=servicecontrol-monitoring +MONITORING_PORT=33633 + +# Kestrel HTTPS +MONITORING_HTTPS_ENABLED=true +MONITORING_HTTPS_CERTIFICATEPATH=/certs/servicecontrol-monitoring.pfx +MONITORING_HTTPS_CERTIFICATEPASSWORD=your-certificate-password +MONITORING_HTTPS_ENABLEHSTS=true + +# Authentication +MONITORING_AUTHENTICATION_ENABLED=true +MONITORING_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0 +MONITORING_AUTHENTICATION_AUDIENCE=api://servicecontrol + +# Restrict CORS +MONITORING_CORS_ALLOWEDORIGINS=https://servicepulse + +# No forwarded headers +MONITORING_FORWARDEDHEADERS_ENABLED=false +``` + --- -### Scenario 6: End-to-End Encryption with Reverse Proxy and Authentication +### Scenario 3: End-to-End Encryption with Reverse Proxy + +For environments requiring encryption of internal traffic. The reverse proxy terminates external TLS and re-encrypts traffic to ServiceControl over HTTPS. + +**Architecture:** -End-to-end TLS encryption where the reverse proxy terminates external TLS and re-encrypts traffic to ServiceControl over HTTPS. Includes JWT authentication. +```text +Client → HTTPS → Reverse Proxy → HTTPS → ServiceControl (Kestrel) + (TLS termination) (TLS + JWT validation) +``` **Security Features:** -| Feature | Status | -|---------|--------| -| JWT Authentication | ✅ Enabled | -| Kestrel HTTPS | ✅ Enabled | -| HTTPS Redirection | ❌ Disabled (handled by proxy) | -| HSTS | ❌ Disabled (handled by proxy) | -| Restricted CORS Origins | ✅ Enabled | -| Forwarded Headers | ✅ Enabled | -| Restricted Proxy Trust | ✅ Enabled | +| Feature | Status | +|----------------------------|--------------------------| +| JWT Authentication | ✅ Enabled | +| Kestrel HTTPS | ✅ Enabled | +| HTTPS Redirection | N/A (no HTTP endpoint) | +| HSTS | N/A (configure at proxy) | +| Restricted CORS Origins | ✅ Enabled | +| Forwarded Headers | ✅ Enabled | +| Restricted Proxy Trust | ✅ Enabled | +| Internal Traffic Encrypted | ✅ Yes | + +> **Note:** HTTPS redirection and HSTS are not applicable in this scenario because ServiceControl only exposes an HTTPS endpoint (Kestrel HTTPS is enabled). There is no HTTP endpoint to redirect from. The reverse proxy is responsible for redirecting external HTTP requests to HTTPS and sending HSTS headers to browsers. Compare this to Scenario 1, where Kestrel HTTPS is disabled and ServiceControl exposes an HTTP endpoint - in that case, HTTPS redirection can optionally be enabled as defense-in-depth. + +#### Primary Instance Configuration (End-to-End Encryption) + +**App.config:** ```xml + + + + + + + - - - - - - - - - - - - - + + + @@ -288,81 +561,171 @@ End-to-end TLS encryption where the reverse proxy terminates external TLS and re - + ``` +**Environment variables:** + +```bash +# Host and Port +SERVICECONTROL_HOSTNAME=localhost +SERVICECONTROL_PORT=33333 + +# Remote Audit Instance(s) +SERVICECONTROL_REMOTEINSTANCES=[{"api_uri":"https://servicecontrol-audit/api"}] + +# Kestrel HTTPS for internal encryption +SERVICECONTROL_HTTPS_ENABLED=true +SERVICECONTROL_HTTPS_CERTIFICATEPATH=/certs/servicecontrol-internal.pfx +SERVICECONTROL_HTTPS_CERTIFICATEPASSWORD=your-certificate-password + +# Authentication +SERVICECONTROL_AUTHENTICATION_ENABLED=true +SERVICECONTROL_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0 +SERVICECONTROL_AUTHENTICATION_AUDIENCE=api://servicecontrol + +# ServicePulse client configuration +SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID={servicepulse-client-id} +SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES=["api://servicecontrol/access_as_user"] + +# Forwarded headers - trust only your reverse proxy +SERVICECONTROL_FORWARDEDHEADERS_ENABLED=true +SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES=false +SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES=10.0.0.5 + +# Restrict CORS +SERVICECONTROL_CORS_ALLOWEDORIGINS=https://servicepulse +``` + +> **Note:** Audit and Monitoring instances follow the same pattern. See Scenario 1 for the authentication and forwarded headers configuration, and add the HTTPS settings as shown above. + +--- + +## Certificate Management + +### Certificate Requirements + +- Use certificates from a trusted Certificate Authority (CA) for production +- For internal deployments, an internal/enterprise CA is acceptable +- Certificates must include the hostname in the Subject Alternative Name (SAN) +- Minimum key size: 2048-bit RSA or 256-bit ECC + +### Certificate Formats + +ServiceControl supports PFX (PKCS#12) certificate files: + +```xml + + +``` + +### Certificate Storage Best Practices + +1. **File permissions**: Restrict access to certificate files to the service account running ServiceControl +2. **Password protection**: Use strong passwords for PFX files +3. **Secure storage**: Store certificates in a secure location with appropriate access controls +4. **Avoid source control**: Never commit certificates or passwords to source control + +### Certificate Renewal + +1. Obtain a new certificate before the current one expires +2. Replace the certificate file at the configured path +3. Restart the ServiceControl service to load the new certificate +4. Verify HTTPS connectivity after restart + --- ## Configuration Reference ### Authentication Settings -| Setting | Type | Default | Description | -|---------|------|---------|-------------| -| `Authentication.Enabled` | bool | `false` | Enable JWT Bearer authentication | -| `Authentication.Authority` | string | - | OpenID Connect authority URL (required when enabled) | -| `Authentication.Audience` | string | - | Expected audience for tokens (required when enabled) | -| `Authentication.ValidateIssuer` | bool | `true` | Validate token issuer | -| `Authentication.ValidateAudience` | bool | `true` | Validate token audience | -| `Authentication.ValidateLifetime` | bool | `true` | Validate token expiration | -| `Authentication.ValidateIssuerSigningKey` | bool | `true` | Validate token signing key | -| `Authentication.RequireHttpsMetadata` | bool | `true` | Require HTTPS for metadata endpoint | -| `Authentication.ServicePulse.ClientId` | string | - | OAuth client ID for ServicePulse | -| `Authentication.ServicePulse.Authority` | string | - | Authority URL for ServicePulse (defaults to main Authority) | -| `Authentication.ServicePulse.ApiScopes` | string | - | API scopes for ServicePulse to request | +| Setting | Type | Default | Description | +|-------------------------------------------|--------|---------|-------------------------------------------------------------| +| `Authentication.Enabled` | bool | `false` | Enable JWT Bearer authentication | +| `Authentication.Authority` | string | - | OpenID Connect authority URL (required when enabled) | +| `Authentication.Audience` | string | - | Expected audience for tokens (required when enabled) | +| `Authentication.ValidateIssuer` | bool | `true` | Validate token issuer | +| `Authentication.ValidateAudience` | bool | `true` | Validate token audience | +| `Authentication.ValidateLifetime` | bool | `true` | Validate token expiration | +| `Authentication.ValidateIssuerSigningKey` | bool | `true` | Validate token signing key | +| `Authentication.RequireHttpsMetadata` | bool | `true` | Require HTTPS for metadata endpoint | +| `Authentication.ServicePulse.ClientId` | string | - | OAuth client ID for ServicePulse (Primary only) | +| `Authentication.ServicePulse.Authority` | string | - | Authority URL for ServicePulse (defaults to main Authority) | +| `Authentication.ServicePulse.ApiScopes` | string | - | API scopes for ServicePulse to request | ### HTTPS Settings -| Setting | Type | Default | Description | -|---------|------|---------|-------------| -| `Https.Enabled` | bool | `false` | Enable Kestrel HTTPS with certificate | -| `Https.CertificatePath` | string | - | Path to PFX/PEM certificate file | -| `Https.CertificatePassword` | string | - | Certificate password (if required) | -| `Https.RedirectHttpToHttps` | bool | `false` | Redirect HTTP requests to HTTPS | -| `Https.EnableHsts` | bool | `false` | Enable HTTP Strict Transport Security | -| `Https.HstsMaxAgeSeconds` | int | `31536000` | HSTS max-age in seconds (1 year) | -| `Https.HstsIncludeSubDomains` | bool | `false` | Include subdomains in HSTS | +| Setting | Type | Default | Description | +|-------------------------------|--------|------------|--------------------------------------------------------| +| `Https.Enabled` | bool | `false` | Enable Kestrel HTTPS with certificate | +| `Https.CertificatePath` | string | - | Path to PFX certificate file | +| `Https.CertificatePassword` | string | - | Certificate password | +| `Https.RedirectHttpToHttps` | bool | `false` | Redirect HTTP requests to HTTPS | +| `Https.Port` | int | - | HTTPS port for redirects (required with reverse proxy) | +| `Https.EnableHsts` | bool | `false` | Enable HTTP Strict Transport Security | +| `Https.HstsMaxAgeSeconds` | int | `31536000` | HSTS max-age in seconds (1 year) | +| `Https.HstsIncludeSubDomains` | bool | `false` | Include subdomains in HSTS | ### Forwarded Headers Settings -| Setting | Type | Default | Description | -|---------|------|---------|-------------| -| `ForwardedHeaders.Enabled` | bool | `true` | Enable forwarded headers processing | -| `ForwardedHeaders.TrustAllProxies` | bool | `true` | Trust X-Forwarded-* from any source | -| `ForwardedHeaders.KnownProxies` | string | - | Comma-separated list of trusted proxy IPs | -| `ForwardedHeaders.KnownNetworks` | string | - | Comma-separated list of trusted CIDR networks | +> **⚠️ Security Warning:** Never set `TrustAllProxies` to `true` in production when ServiceControl is accessible from untrusted networks. This can allow attackers to spoof client IP addresses and bypass security controls. + +| Setting | Type | Default | Description | +|------------------------------------|--------|---------|-----------------------------------------------| +| `ForwardedHeaders.Enabled` | bool | `true` | Enable forwarded headers processing | +| `ForwardedHeaders.TrustAllProxies` | bool | `true` | Trust X-Forwarded-* from any source | +| `ForwardedHeaders.KnownProxies` | string | - | Comma-separated list of trusted proxy IPs | +| `ForwardedHeaders.KnownNetworks` | string | - | Comma-separated list of trusted CIDR networks | > **Note:** If `KnownProxies` or `KnownNetworks` are configured, `TrustAllProxies` is automatically set to `false`. ### CORS Settings -| Setting | Type | Default | Description | -|---------|------|---------|-------------| -| `Cors.AllowAnyOrigin` | bool | `true` | Allow requests from any origin | -| `Cors.AllowedOrigins` | string | - | Comma-separated list of allowed origins | +| Setting | Type | Default | Description | +|-----------------------|--------|---------|-----------------------------------------| +| `Cors.AllowAnyOrigin` | bool | `true` | Allow requests from any origin | +| `Cors.AllowedOrigins` | string | - | Comma-separated list of allowed origins | > **Note:** If `AllowedOrigins` is configured, `AllowAnyOrigin` is automatically set to `false`. +### Host Settings + +| Setting | Type | Default | Description | +|------------|--------|-------------|-----------------------| +| `Hostname` | string | `localhost` | Hostname to listen on | +| `Port` | int | varies | Port to listen on | + +### Remote Instance Settings (Primary Only) + +| Setting | Type | Default | Description | +|-------------------|--------|---------|------------------------------------------| +| `RemoteInstances` | string | - | JSON array of remote Audit instance URIs | + --- ## Scenario Comparison Matrix -| Feature | Default | Reverse Proxy (SSL Termination) | Direct HTTPS | Reverse Proxy + Auth | Direct HTTPS + Auth | End-to-End Encryption + Auth | -|---------|:-------:|:-------------------------------:|:------------:|:--------------------:|:-------------------:|:----------------------------:| -| **JWT Authentication** | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ | -| **Direct (Kestrel) HTTPS** | ❌ | ❌ | ✅ | ❌ | ✅ | ✅ | -| **HTTPS Redirection** | ❌ | ❌ | ✅ | ❌ | ✅ | ❌ (Handled by Reverse Proxy) | -| **HSTS** | ❌ | ❌ | ✅ | ❌ | ✅ | ❌ (Handled by Reverse Proxy) | -| **Restricted CORS** | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | -| **Forwarded Headers** | ✅ | ✅ | ❌ | ✅ | ❌ (Not needed. No Reverse Proxy) | ✅ | -| **Restricted Proxy Trust** | ❌ | ✅ | N/A | ✅ | N/A | ✅ | -| | | | | | | | -| **Reverse Proxy** | Optional | Yes | No | Yes | No | Yes | -| **Internal Traffic Encrypted** | ❌ | ❌ | ✅ | ❌ | ✅ | ✅ | - -**Legend:** - -- ✅ = Enabled -- ❌ = Disabled -- N/A = Not Applicable +| Feature | Reverse Proxy + Auth | Direct HTTPS + Auth | End-to-End Encryption | +|--------------------------------|:--------------------:|:-------------------:|:---------------------:| +| **JWT Authentication** | ✅ | ✅ | ✅ | +| **Kestrel HTTPS** | ❌ | ✅ | ✅ | +| **HTTPS Redirection** | ✅ (optional) | ✅ | ❌ (at proxy) | +| **HSTS** | ❌ (at proxy) | ✅ | ❌ (at proxy) | +| **Restricted CORS** | ✅ | ✅ | ✅ | +| **Forwarded Headers** | ✅ | ❌ | ✅ | +| **Restricted Proxy Trust** | ✅ | N/A | ✅ | +| **Internal Traffic Encrypted** | ❌ | ✅ | ✅ | +| | | | | +| **Requires Reverse Proxy** | Yes | No | Yes | +| **Certificate Management** | At proxy only | At ServiceControl | Both | + +--- + +## See Also + +- [Authentication Configuration](authentication.md) - Detailed authentication setup guide +- [HTTPS Configuration](https-configuration.md) - Detailed HTTPS setup guide +- [Forwarded Headers Configuration](forwarded-headers.md) - Forwarded headers reference +- [Reverse Proxy Testing](reverseproxy-testing.md) - Local testing with NGINX +- [Authentication Testing](authentication-testing.md) - Testing authentication scenarios diff --git a/docs/https-configuration.md b/docs/https-configuration.md index dee9744847..9dfdf0680a 100644 --- a/docs/https-configuration.md +++ b/docs/https-configuration.md @@ -8,30 +8,30 @@ ServiceControl instances can be configured via environment variables or App.conf ### Environment Variables -| Instance | Prefix | -|----------|--------| -| ServiceControl (Primary) | `SERVICECONTROL_` | -| ServiceControl.Audit | `SERVICECONTROL_AUDIT_` | -| ServiceControl.Monitoring | `MONITORING_` | - -| Setting | Default | Description | -|---------|---------|-------------| -| `{PREFIX}HTTPS_ENABLED` | `false` | Enable HTTPS with Kestrel | -| `{PREFIX}HTTPS_CERTIFICATEPATH` | (none) | Path to the certificate file (.pfx) | -| `{PREFIX}HTTPS_CERTIFICATEPASSWORD` | (none) | Password for the certificate file | -| `{PREFIX}HTTPS_REDIRECTHTTPTOHTTPS` | `false` | Redirect HTTP requests to HTTPS | -| `{PREFIX}HTTPS_PORT` | (none) | HTTPS port for redirect (required for reverse proxy scenarios) | -| `{PREFIX}HTTPS_ENABLEHSTS` | `false` | Enable HTTP Strict Transport Security | -| `{PREFIX}HTTPS_HSTSMAXAGESECONDS` | `31536000` | HSTS max-age in seconds (default: 1 year) | -| `{PREFIX}HTTPS_HSTSINCLUDESUBDOMAINS` | `false` | Include subdomains in HSTS policy | +| Instance | Prefix | +|---------------------------|-------------------------| +| ServiceControl (Primary) | `SERVICECONTROL_` | +| ServiceControl.Audit | `SERVICECONTROL_AUDIT_` | +| ServiceControl.Monitoring | `MONITORING_` | + +| Setting | Default | Description | +|---------------------------------------|------------|----------------------------------------------------------------| +| `{PREFIX}HTTPS_ENABLED` | `false` | Enable HTTPS with Kestrel | +| `{PREFIX}HTTPS_CERTIFICATEPATH` | (none) | Path to the certificate file (.pfx) | +| `{PREFIX}HTTPS_CERTIFICATEPASSWORD` | (none) | Password for the certificate file | +| `{PREFIX}HTTPS_REDIRECTHTTPTOHTTPS` | `false` | Redirect HTTP requests to HTTPS | +| `{PREFIX}HTTPS_PORT` | (none) | HTTPS port for redirect (required for reverse proxy scenarios) | +| `{PREFIX}HTTPS_ENABLEHSTS` | `false` | Enable HTTP Strict Transport Security | +| `{PREFIX}HTTPS_HSTSMAXAGESECONDS` | `31536000` | HSTS max-age in seconds (default: 1 year) | +| `{PREFIX}HTTPS_HSTSINCLUDESUBDOMAINS` | `false` | Include subdomains in HSTS policy | ### App.config -| Instance | Key Prefix | -|----------|------------| -| ServiceControl (Primary) | `ServiceControl/` | -| ServiceControl.Audit | `ServiceControl.Audit/` | -| ServiceControl.Monitoring | `Monitoring/` | +| Instance | Key Prefix | +|---------------------------|-------------------------| +| ServiceControl (Primary) | `ServiceControl/` | +| ServiceControl.Audit | `ServiceControl.Audit/` | +| ServiceControl.Monitoring | `Monitoring/` | ```xml @@ -71,12 +71,50 @@ set SERVICECONTROL_HTTPS_PORT=443 ## Security Considerations -### Certificate Security +### Certificate Password Security + +The certificate password is read as plain text from configuration. To minimize security risks: + +#### Option 1: Use a certificate without a password (Recommended) + +If the certificate file is protected with proper file system permissions, a password may not be necessary: + +```bash +# Export certificate without password protection +openssl pkcs12 -in cert-with-password.pfx -out cert-no-password.pfx -nodes +``` + +Then restrict file access: + +- **Windows:** Grant read access only to the service account running ServiceControl +- **Linux/Container:** Set file permissions to `400` (owner read only) + +#### Option 2: Use platform secrets management + +For container and cloud deployments, use the platform's secrets management instead of plain environment variables: + +| Platform | Secrets Solution | +|----------|------------------| +| Kubernetes | [Kubernetes Secrets](https://kubernetes.io/docs/concepts/configuration/secret/) mounted as environment variables | +| Docker Swarm | [Docker Secrets](https://docs.docker.com/engine/swarm/secrets/) | +| Azure | [Azure Key Vault](https://learn.microsoft.com/en-us/azure/key-vault/) with managed identity | +| AWS | [AWS Secrets Manager](https://aws.amazon.com/secrets-manager/) or [SSM Parameter Store](https://docs.aws.amazon.com/systems-manager/latest/userguide/systems-manager-parameter-store.html) | + +#### Option 3: Restrict file system access + +If you must use a password-protected certificate: + +- Never commit certificates or passwords to source control +- Restrict read access to the certificate file to only the ServiceControl service account +- Use environment variables rather than App.config (environment variables are not persisted to disk) +- Consider using Windows DPAPI or similar platform-specific encryption for config files + +### Certificate File Security - Store certificate files securely with appropriate file permissions -- Use strong passwords for certificate files - Rotate certificates before expiration - Use certificates from a trusted Certificate Authority for production +- Never commit certificate files to source control ### HSTS Considerations diff --git a/docs/https-testing.md b/docs/https-testing.md index 3216acfbb3..69bab0e7e4 100644 --- a/docs/https-testing.md +++ b/docs/https-testing.md @@ -6,11 +6,11 @@ This guide provides scenario-based tests for ServiceControl's direct HTTPS featu ## Instance Reference -| Instance | Project Directory | Default Port | Environment Variable Prefix | App.config Key Prefix | -|----------|-------------------|--------------|-----------------------------|-----------------------| -| ServiceControl (Primary) | `src\ServiceControl` | 33333 | `SERVICECONTROL_` | `ServiceControl/` | -| ServiceControl.Audit | `src\ServiceControl.Audit` | 44444 | `SERVICECONTROL_AUDIT_` | `ServiceControl.Audit/` | -| ServiceControl.Monitoring | `src\ServiceControl.Monitoring` | 33633 | `MONITORING_` | `Monitoring/` | +| Instance | Project Directory | Default Port | Environment Variable Prefix | App.config Key Prefix | +|---------------------------|---------------------------------|--------------|-----------------------------|-------------------------| +| ServiceControl (Primary) | `src\ServiceControl` | 33333 | `SERVICECONTROL_` | `ServiceControl/` | +| ServiceControl.Audit | `src\ServiceControl.Audit` | 44444 | `SERVICECONTROL_AUDIT_` | `ServiceControl.Audit/` | +| ServiceControl.Monitoring | `src\ServiceControl.Monitoring` | 33633 | `MONITORING_` | `Monitoring/` | > **Note:** Environment variables must include the instance prefix (e.g., `SERVICECONTROL_HTTPS_ENABLED` for the primary instance). @@ -154,15 +154,15 @@ HTTP requests fail because Kestrel is listening for HTTPS but receives plaintext ## HTTPS Configuration Reference -| App.config Key | Environment Variable (Primary) | Default | Description | -|----------------|-------------------------------|---------|-------------| -| `Https.Enabled` | `SERVICECONTROL_HTTPS_ENABLED` | `false` | Enable Kestrel HTTPS | -| `Https.CertificatePath` | `SERVICECONTROL_HTTPS_CERTIFICATEPATH` | - | Path to PFX certificate file | -| `Https.CertificatePassword` | `SERVICECONTROL_HTTPS_CERTIFICATEPASSWORD` | - | Certificate password (empty string for no password) | -| `Https.RedirectHttpToHttps` | `SERVICECONTROL_HTTPS_REDIRECTHTTPTOHTTPS` | `false` | Redirect HTTP requests to HTTPS (reverse proxy only) | -| `Https.EnableHsts` | `SERVICECONTROL_HTTPS_ENABLEHSTS` | `false` | Enable HTTP Strict Transport Security | -| `Https.HstsMaxAgeSeconds` | `SERVICECONTROL_HTTPS_HSTSMAXAGESECONDS` | `31536000` | HSTS max-age (1 year) | -| `Https.HstsIncludeSubDomains` | `SERVICECONTROL_HTTPS_HSTSINCLUDESUBDOMAINS` | `false` | Include subdomains in HSTS | +| App.config Key | Environment Variable (Primary) | Default | Description | +|-------------------------------|----------------------------------------------|------------|------------------------------------------------------| +| `Https.Enabled` | `SERVICECONTROL_HTTPS_ENABLED` | `false` | Enable Kestrel HTTPS | +| `Https.CertificatePath` | `SERVICECONTROL_HTTPS_CERTIFICATEPATH` | - | Path to PFX certificate file | +| `Https.CertificatePassword` | `SERVICECONTROL_HTTPS_CERTIFICATEPASSWORD` | - | Certificate password (empty string for no password) | +| `Https.RedirectHttpToHttps` | `SERVICECONTROL_HTTPS_REDIRECTHTTPTOHTTPS` | `false` | Redirect HTTP requests to HTTPS (reverse proxy only) | +| `Https.EnableHsts` | `SERVICECONTROL_HTTPS_ENABLEHSTS` | `false` | Enable HTTP Strict Transport Security | +| `Https.HstsMaxAgeSeconds` | `SERVICECONTROL_HTTPS_HSTSMAXAGESECONDS` | `31536000` | HSTS max-age (1 year) | +| `Https.HstsIncludeSubDomains` | `SERVICECONTROL_HTTPS_HSTSINCLUDESUBDOMAINS` | `false` | Include subdomains in HSTS | > **Note:** For other instances, replace the `SERVICECONTROL_` prefix with the appropriate instance prefix (see Instance Reference table). > @@ -175,11 +175,11 @@ The scenarios above use ServiceControl (Primary). To test ServiceControl.Audit o 1. Use the appropriate environment variable prefix (see Instance Reference above) 2. Use the corresponding project directory and port -| Instance | Project Directory | Port | Env Var Prefix | -|----------|-------------------|------|----------------| -| ServiceControl (Primary) | `src\ServiceControl` | 33333 | `SERVICECONTROL_` | -| ServiceControl.Audit | `src\ServiceControl.Audit` | 44444 | `SERVICECONTROL_AUDIT_` | -| ServiceControl.Monitoring | `src\ServiceControl.Monitoring` | 33633 | `MONITORING_` | +| Instance | Project Directory | Port | Env Var Prefix | +|---------------------------|---------------------------------|-------|-------------------------| +| ServiceControl (Primary) | `src\ServiceControl` | 33333 | `SERVICECONTROL_` | +| ServiceControl.Audit | `src\ServiceControl.Audit` | 44444 | `SERVICECONTROL_AUDIT_` | +| ServiceControl.Monitoring | `src\ServiceControl.Monitoring` | 33633 | `MONITORING_` | ## Troubleshooting diff --git a/docs/reverseproxy-testing.md b/docs/reverseproxy-testing.md index 291262d982..e3ce5a92fb 100644 --- a/docs/reverseproxy-testing.md +++ b/docs/reverseproxy-testing.md @@ -10,11 +10,11 @@ This guide provides scenario-based tests for ServiceControl instances behind an ## Instance Reference -| Instance | Project Directory | Default Port | Hostname | Environment Variable Prefix | -|----------|-------------------|--------------|----------|----------------------------| -| ServiceControl (Primary) | `src\ServiceControl` | 33333 | `servicecontrol.localhost` | `SERVICECONTROL_` | -| ServiceControl.Audit | `src\ServiceControl.Audit` | 44444 | `servicecontrol-audit.localhost` | `SERVICECONTROL_AUDIT_` | -| ServiceControl.Monitoring | `src\ServiceControl.Monitoring` | 33633 | `servicecontrol-monitor.localhost` | `MONITORING_` | +| Instance | Project Directory | Default Port | Hostname | Environment Variable Prefix | +|---------------------------|---------------------------------|--------------|------------------------------------|-----------------------------| +| ServiceControl (Primary) | `src\ServiceControl` | 33333 | `servicecontrol.localhost` | `SERVICECONTROL_` | +| ServiceControl.Audit | `src\ServiceControl.Audit` | 44444 | `servicecontrol-audit.localhost` | `SERVICECONTROL_AUDIT_` | +| ServiceControl.Monitoring | `src\ServiceControl.Monitoring` | 33633 | `servicecontrol-monitor.localhost` | `MONITORING_` | ## Prerequisites @@ -421,25 +421,25 @@ The scenarios above use ServiceControl (Primary). To test ServiceControl.Audit o 1. Use the appropriate environment variable prefix (see Configuration Reference below) 2. Use the corresponding project directory and hostname -| Instance | Project Directory | Hostname | Env Var Prefix | -|----------|-------------------|----------|----------------| -| ServiceControl (Primary) | `src\ServiceControl` | `servicecontrol.localhost` | `SERVICECONTROL_` | -| ServiceControl.Audit | `src\ServiceControl.Audit` | `servicecontrol-audit.localhost` | `SERVICECONTROL_AUDIT_` | -| ServiceControl.Monitoring | `src\ServiceControl.Monitoring` | `servicecontrol-monitor.localhost` | `MONITORING_` | +| Instance | Project Directory | Hostname | Env Var Prefix | +|---------------------------|---------------------------------|------------------------------------|-------------------------| +| ServiceControl (Primary) | `src\ServiceControl` | `servicecontrol.localhost` | `SERVICECONTROL_` | +| ServiceControl.Audit | `src\ServiceControl.Audit` | `servicecontrol-audit.localhost` | `SERVICECONTROL_AUDIT_` | +| ServiceControl.Monitoring | `src\ServiceControl.Monitoring` | `servicecontrol-monitor.localhost` | `MONITORING_` | ## Configuration Reference -| Environment Variable | Default | Description | -|---------------------|---------|-------------| -| `{PREFIX}_FORWARDEDHEADERS_ENABLED` | `true` | Enable forwarded headers processing | -| `{PREFIX}_FORWARDEDHEADERS_TRUSTALLPROXIES` | `true` | Trust all proxies | -| `{PREFIX}_FORWARDEDHEADERS_KNOWNPROXIES` | - | Comma-separated list of trusted proxy IPs | -| `{PREFIX}_FORWARDEDHEADERS_KNOWNNETWORKS` | - | Comma-separated list of trusted CIDR ranges | -| `{PREFIX}_HTTPS_REDIRECTHTTPTOHTTPS` | `false` | Redirect HTTP to HTTPS | -| `{PREFIX}_HTTPS_PORT` | - | HTTPS port for redirect | -| `{PREFIX}_HTTPS_ENABLEHSTS` | `false` | Enable HSTS | -| `{PREFIX}_HTTPS_HSTSMAXAGESECONDS` | `31536000` | HSTS max-age (1 year) | -| `{PREFIX}_HTTPS_HSTSINCLUDESUBDOMAINS` | `false` | Include subdomains in HSTS | +| Environment Variable | Default | Description | +|---------------------------------------------|------------|---------------------------------------------| +| `{PREFIX}_FORWARDEDHEADERS_ENABLED` | `true` | Enable forwarded headers processing | +| `{PREFIX}_FORWARDEDHEADERS_TRUSTALLPROXIES` | `true` | Trust all proxies | +| `{PREFIX}_FORWARDEDHEADERS_KNOWNPROXIES` | - | Comma-separated list of trusted proxy IPs | +| `{PREFIX}_FORWARDEDHEADERS_KNOWNNETWORKS` | - | Comma-separated list of trusted CIDR ranges | +| `{PREFIX}_HTTPS_REDIRECTHTTPTOHTTPS` | `false` | Redirect HTTP to HTTPS | +| `{PREFIX}_HTTPS_PORT` | - | HTTPS port for redirect | +| `{PREFIX}_HTTPS_ENABLEHSTS` | `false` | Enable HSTS | +| `{PREFIX}_HTTPS_HSTSMAXAGESECONDS` | `31536000` | HSTS max-age (1 year) | +| `{PREFIX}_HTTPS_HSTSINCLUDESUBDOMAINS` | `false` | Include subdomains in HSTS | Where `{PREFIX}` is: diff --git a/docs/testing-architecture.md b/docs/testing-architecture.md index 5f7f894f54..ea1d5000e7 100644 --- a/docs/testing-architecture.md +++ b/docs/testing-architecture.md @@ -10,53 +10,53 @@ The repository contains 28 test projects organized into several categories: ### Unit Test Projects -| Project | Purpose | -|---------|---------| -| `ServiceControl.UnitTests` | Primary instance unit tests | -| `ServiceControl.Audit.UnitTests` | Audit instance unit tests | -| `ServiceControl.Monitoring.UnitTests` | Monitoring instance unit tests | -| `ServiceControl.Infrastructure.Tests` | Shared infrastructure tests | -| `ServiceControl.Config.Tests` | WPF configuration UI tests | -| `ServiceControlInstaller.Engine.UnitTests` | Windows service installer tests | -| `ServiceControlInstaller.Packaging.UnitTests` | Packaging utilities tests | -| `Particular.LicensingComponent.UnitTests` | Licensing component tests | +| Project | Purpose | +|-----------------------------------------------|---------------------------------| +| `ServiceControl.UnitTests` | Primary instance unit tests | +| `ServiceControl.Audit.UnitTests` | Audit instance unit tests | +| `ServiceControl.Monitoring.UnitTests` | Monitoring instance unit tests | +| `ServiceControl.Infrastructure.Tests` | Shared infrastructure tests | +| `ServiceControl.Config.Tests` | WPF configuration UI tests | +| `ServiceControlInstaller.Engine.UnitTests` | Windows service installer tests | +| `ServiceControlInstaller.Packaging.UnitTests` | Packaging utilities tests | +| `Particular.LicensingComponent.UnitTests` | Licensing component tests | ### Persistence Test Projects -| Project | Purpose | -|---------|---------| -| `ServiceControl.Persistence.Tests` | Abstract persistence layer tests | -| `ServiceControl.Persistence.Tests.RavenDB` | RavenDB persistence implementation | -| `ServiceControl.Persistence.Tests.InMemory` | In-memory persistence tests | -| `ServiceControl.Audit.Persistence.Tests` | Audit persistence abstractions | -| `ServiceControl.Audit.Persistence.Tests.RavenDB` | Audit RavenDB tests | +| Project | Purpose | +|--------------------------------------------------|------------------------------------| +| `ServiceControl.Persistence.Tests` | Abstract persistence layer tests | +| `ServiceControl.Persistence.Tests.RavenDB` | RavenDB persistence implementation | +| `ServiceControl.Persistence.Tests.InMemory` | In-memory persistence tests | +| `ServiceControl.Audit.Persistence.Tests` | Audit persistence abstractions | +| `ServiceControl.Audit.Persistence.Tests.RavenDB` | Audit RavenDB tests | ### Acceptance Test Projects -| Project | Purpose | -|---------|---------| -| `ServiceControl.AcceptanceTests` | Primary instance shared acceptance test code | -| `ServiceControl.AcceptanceTests.RavenDB` | Primary instance with RavenDB | -| `ServiceControl.Audit.AcceptanceTests` | Audit instance shared acceptance test code | -| `ServiceControl.Audit.AcceptanceTests.RavenDB` | Audit with RavenDB | -| `ServiceControl.Monitoring.AcceptanceTests` | Monitoring instance acceptance tests | -| `ServiceControl.MultiInstance.AcceptanceTests` | Multi-instance integration tests | +| Project | Purpose | +|------------------------------------------------|----------------------------------------------| +| `ServiceControl.AcceptanceTests` | Primary instance shared acceptance test code | +| `ServiceControl.AcceptanceTests.RavenDB` | Primary instance with RavenDB | +| `ServiceControl.Audit.AcceptanceTests` | Audit instance shared acceptance test code | +| `ServiceControl.Audit.AcceptanceTests.RavenDB` | Audit with RavenDB | +| `ServiceControl.Monitoring.AcceptanceTests` | Monitoring instance acceptance tests | +| `ServiceControl.MultiInstance.AcceptanceTests` | Multi-instance integration tests | ### Transport Test Projects -| Project | Filter Value | -|---------|--------------| -| `ServiceControl.Transports.Tests` | Default (Learning Transport) | -| `ServiceControl.Transports.ASBS.Tests` | AzureServiceBus | -| `ServiceControl.Transports.ASQ.Tests` | AzureStorageQueues | -| `ServiceControl.Transports.Msmq.Tests` | MSMQ | -| `ServiceControl.Transports.PostgreSql.Tests` | PostgreSql | -| `ServiceControl.Transports.RabbitMQClassicConventionalRouting.Tests` | RabbitMQ | -| `ServiceControl.Transports.RabbitMQClassicDirectRouting.Tests` | RabbitMQ | -| `ServiceControl.Transports.RabbitMQQuorumConventionalRouting.Tests` | RabbitMQ | -| `ServiceControl.Transports.RabbitMQQuorumDirectRouting.Tests` | RabbitMQ | -| `ServiceControl.Transports.SqlServer.Tests` | SqlServer | -| `ServiceControl.Transports.SQS.Tests` | SQS | +| Project | Filter Value | +|----------------------------------------------------------------------|------------------------------| +| `ServiceControl.Transports.Tests` | Default (Learning Transport) | +| `ServiceControl.Transports.ASBS.Tests` | AzureServiceBus | +| `ServiceControl.Transports.ASQ.Tests` | AzureStorageQueues | +| `ServiceControl.Transports.Msmq.Tests` | MSMQ | +| `ServiceControl.Transports.PostgreSql.Tests` | PostgreSql | +| `ServiceControl.Transports.RabbitMQClassicConventionalRouting.Tests` | RabbitMQ | +| `ServiceControl.Transports.RabbitMQClassicDirectRouting.Tests` | RabbitMQ | +| `ServiceControl.Transports.RabbitMQQuorumConventionalRouting.Tests` | RabbitMQ | +| `ServiceControl.Transports.RabbitMQQuorumDirectRouting.Tests` | RabbitMQ | +| `ServiceControl.Transports.SqlServer.Tests` | SqlServer | +| `ServiceControl.Transports.SQS.Tests` | SQS | ## Testing Framework and Conventions @@ -117,16 +117,16 @@ Tests can be filtered by transport using the `ServiceControl_TESTS_FILTER` envir Located in `src/TestHelper/IncludeInTestsAttribute.cs`: -| Attribute | Filter Value | -|-----------|--------------| -| `[IncludeInDefaultTests]` | Default | -| `[IncludeInAzureServiceBusTests]` | AzureServiceBus | +| Attribute | Filter Value | +|--------------------------------------|--------------------| +| `[IncludeInDefaultTests]` | Default | +| `[IncludeInAzureServiceBusTests]` | AzureServiceBus | | `[IncludeInAzureStorageQueuesTests]` | AzureStorageQueues | -| `[IncludeInMsmqTests]` | MSMQ | -| `[IncludeInPostgreSqlTests]` | PostgreSql | -| `[IncludeInRabbitMQTests]` | RabbitMQ | -| `[IncludeInSqlServerTests]` | SqlServer | -| `[IncludeInAmazonSqsTests]` | SQS | +| `[IncludeInMsmqTests]` | MSMQ | +| `[IncludeInPostgreSqlTests]` | PostgreSql | +| `[IncludeInRabbitMQTests]` | RabbitMQ | +| `[IncludeInSqlServerTests]` | SqlServer | +| `[IncludeInAmazonSqsTests]` | SQS | ### Usage @@ -505,14 +505,14 @@ dotnet test src/ServiceControl.sln --logger "console;verbosity=detailed" ## Environment Variables for Transport Tests -| Transport | Environment Variable | -|-----------|---------------------| -| SQL Server | `ServiceControl_TransportTests_SQL_ConnectionString` | -| Azure Service Bus | `ServiceControl_TransportTests_ASBS_ConnectionString` | -| Azure Storage Queues | `ServiceControl_TransportTests_ASQ_ConnectionString` | -| RabbitMQ | `ServiceControl_TransportTests_RabbitMQ_ConnectionString` | -| AWS SQS | `ServiceControl_TransportTests_SQS_*` | -| PostgreSQL | `ServiceControl_TransportTests_PostgreSql_ConnectionString` | +| Transport | Environment Variable | +|----------------------|-------------------------------------------------------------| +| SQL Server | `ServiceControl_TransportTests_SQL_ConnectionString` | +| Azure Service Bus | `ServiceControl_TransportTests_ASBS_ConnectionString` | +| Azure Storage Queues | `ServiceControl_TransportTests_ASQ_ConnectionString` | +| RabbitMQ | `ServiceControl_TransportTests_RabbitMQ_ConnectionString` | +| AWS SQS | `ServiceControl_TransportTests_SQS_*` | +| PostgreSQL | `ServiceControl_TransportTests_PostgreSql_ConnectionString` | ## Summary From eb9853e14cc01fe97493fa151407c45f04beb3a7 Mon Sep 17 00:00:00 2001 From: Warwick Schroeder Date: Thu, 18 Dec 2025 13:25:56 +0800 Subject: [PATCH 24/24] Update internal auth docs. Fix issue with server-to-server remote instance checks with auth. --- docs/authentication-testing.md | 36 +- docs/authentication.md | 9 +- docs/forward-headers-testing.md | 19 ++ docs/hosting-guide.md | 316 +++++++++--------- docs/https-testing.md | 21 +- docs/reverseproxy-testing.md | 19 ++ .../Infrastructure/WebApi/RootController.cs | 1 + .../Infrastructure/WebApi/RootController.cs | 3 +- 8 files changed, 265 insertions(+), 159 deletions(-) diff --git a/docs/authentication-testing.md b/docs/authentication-testing.md index 37c72c89ac..91c6a1f91f 100644 --- a/docs/authentication-testing.md +++ b/docs/authentication-testing.md @@ -6,9 +6,32 @@ This guide explains how to test authentication configuration for ServiceControl - ServiceControl built locally (see main README for build instructions) - **HTTPS configured** - Authentication should only be used over HTTPS. Configure HTTPS using one of the methods described in [HTTPS Configuration](https-configuration.md) before testing authentication scenarios. +- **Identity Provider (IdP) configured** - For real authentication testing (Scenarios 7+), you need an OIDC provider configured with: + - An API application registration (for ServiceControl) + - A client application registration (for ServicePulse) + - API scopes configured and permissions granted + - See [Authentication Configuration](authentication.md#configuring-identity-providers) for setup instructions - curl (included with Windows 10/11, Git Bash, or WSL) - (Optional) For formatted JSON output: `npm install -g json` then pipe curl output through `| json` -- (Optional) An OIDC provider for full end-to-end testing (e.g., Microsoft Entra ID, Auth0, Okta) + +## Enabling Debug Logs + +To enable detailed logging for troubleshooting, set the `LogLevel` environment variable before starting each instance: + +```cmd +rem ServiceControl Primary +set SERVICECONTROL_LOGLEVEL=Debug + +rem ServiceControl.Audit +set SERVICECONTROL_AUDIT_LOGLEVEL=Debug + +rem ServiceControl.Monitoring +set MONITORING_LOGLEVEL=Debug +``` + +**Valid log levels:** `Trace`, `Debug`, `Information` (or `Info`), `Warning` (or `Warn`), `Error`, `Critical` (or `Fatal`), `None` (or `Off`) + +Debug logs will show detailed authentication flow information including token validation, claims processing, and authorization decisions. ## Instance Reference @@ -55,6 +78,7 @@ set SERVICECONTROL_AUTHENTICATION_ENABLED= set SERVICECONTROL_AUTHENTICATION_AUTHORITY= set SERVICECONTROL_AUTHENTICATION_AUDIENCE= set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID= +set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_AUTHORITY= set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES= set SERVICECONTROL_AUTHENTICATION_REQUIREHTTPSMETADATA= set SERVICECONTROL_AUTHENTICATION_VALIDATEISSUER= @@ -110,6 +134,7 @@ set SERVICECONTROL_AUTHENTICATION_ENABLED=true set SERVICECONTROL_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/common/v2.0 set SERVICECONTROL_AUTHENTICATION_AUDIENCE=api://servicecontrol-test set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID=test-client-id +set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_AUTHORITY=https://login.microsoftonline.com/common/v2.0 set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES=["api://servicecontrol-test/.default"] set SERVICECONTROL_AUTHENTICATION_REQUIREHTTPSMETADATA= set SERVICECONTROL_AUTHENTICATION_VALIDATEISSUER= @@ -165,6 +190,7 @@ set SERVICECONTROL_AUTHENTICATION_ENABLED=true set SERVICECONTROL_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/common/v2.0 set SERVICECONTROL_AUTHENTICATION_AUDIENCE=api://servicecontrol-test set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID=test-client-id +set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_AUTHORITY=https://login.microsoftonline.com/common/v2.0 set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES=["api://servicecontrol-test/.default"] set SERVICECONTROL_AUTHENTICATION_REQUIREHTTPSMETADATA= set SERVICECONTROL_AUTHENTICATION_VALIDATEISSUER= @@ -240,6 +266,7 @@ set SERVICECONTROL_AUTHENTICATION_ENABLED=true set SERVICECONTROL_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/common/v2.0 set SERVICECONTROL_AUTHENTICATION_AUDIENCE=api://servicecontrol-test set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID=test-client-id +set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_AUTHORITY=https://login.microsoftonline.com/common/v2.0 set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES=["api://servicecontrol-test/.default"] set SERVICECONTROL_AUTHENTICATION_REQUIREHTTPSMETADATA= set SERVICECONTROL_AUTHENTICATION_VALIDATEISSUER=false @@ -269,6 +296,7 @@ set SERVICECONTROL_AUTHENTICATION_ENABLED=true set SERVICECONTROL_AUTHENTICATION_AUTHORITY= set SERVICECONTROL_AUTHENTICATION_AUDIENCE=api://servicecontrol-test set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID=test-client-id +set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_AUTHORITY= set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES=["api://servicecontrol-test/.default"] set SERVICECONTROL_AUTHENTICATION_REQUIREHTTPSMETADATA= set SERVICECONTROL_AUTHENTICATION_VALIDATEISSUER= @@ -312,6 +340,7 @@ set SERVICECONTROL_AUTHENTICATION_ENABLED=true set SERVICECONTROL_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0 set SERVICECONTROL_AUTHENTICATION_AUDIENCE=api://servicecontrol set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID={servicepulse-client-id} +set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0 set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES=["api://servicecontrol/access_as_user"] set SERVICECONTROL_AUTHENTICATION_REQUIREHTTPSMETADATA= set SERVICECONTROL_AUTHENTICATION_VALIDATEISSUER= @@ -375,6 +404,7 @@ set SERVICECONTROL_AUTHENTICATION_ENABLED=true set SERVICECONTROL_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0 set SERVICECONTROL_AUTHENTICATION_AUDIENCE=api://servicecontrol set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID={servicepulse-client-id} +set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0 set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES=["api://servicecontrol/access_as_user"] set SERVICECONTROL_REMOTEINSTANCES=[{"api_uri":"https://localhost:44444"}] @@ -456,6 +486,7 @@ set SERVICECONTROL_AUTHENTICATION_ENABLED=true set SERVICECONTROL_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0 set SERVICECONTROL_AUTHENTICATION_AUDIENCE=api://servicecontrol set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID={servicepulse-client-id} +set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0 set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES=["api://servicecontrol/access_as_user"] set SERVICECONTROL_REMOTEINSTANCES=[{"api_uri":"https://localhost:44444"}] @@ -574,6 +605,7 @@ set SERVICECONTROL_AUTHENTICATION_ENABLED=true set SERVICECONTROL_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0 set SERVICECONTROL_AUTHENTICATION_AUDIENCE=api://servicecontrol set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID={servicepulse-client-id} +set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0 set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES=["api://servicecontrol/access_as_user"] set SERVICECONTROL_REMOTEINSTANCES=[{"api_uri":"https://localhost:44444"}] @@ -799,6 +831,7 @@ set SERVICECONTROL_AUTHENTICATION_ENABLED= set SERVICECONTROL_AUTHENTICATION_AUTHORITY= set SERVICECONTROL_AUTHENTICATION_AUDIENCE= set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID= +set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_AUTHORITY= set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES= set SERVICECONTROL_AUTHENTICATION_VALIDATEISSUER= set SERVICECONTROL_AUTHENTICATION_VALIDATEAUDIENCE= @@ -814,6 +847,7 @@ $env:SERVICECONTROL_AUTHENTICATION_ENABLED = $null $env:SERVICECONTROL_AUTHENTICATION_AUTHORITY = $null $env:SERVICECONTROL_AUTHENTICATION_AUDIENCE = $null $env:SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID = $null +$env:SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_AUTHORITY = $null $env:SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES = $null $env:SERVICECONTROL_AUTHENTICATION_VALIDATEISSUER = $null $env:SERVICECONTROL_AUTHENTICATION_VALIDATEAUDIENCE = $null diff --git a/docs/authentication.md b/docs/authentication.md index c129dd9ed0..72b0e4968d 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -66,10 +66,13 @@ These settings are required on the primary ServiceControl instance to provide au + ``` +> **Note:** The `ServicePulse.Authority` must be set explicitly. The `Audience` for ServicePulse is reused from the main `Authentication.Audience` setting. + ## Examples ### Microsoft Entra ID (Azure AD) @@ -79,13 +82,14 @@ set SERVICECONTROL_AUTHENTICATION_ENABLED=true set SERVICECONTROL_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0 set SERVICECONTROL_AUTHENTICATION_AUDIENCE=api://servicecontrol set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID={servicepulse-client-id} +set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0 set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES=["api://servicecontrol/access_as_user"] ``` ### Docker Example ```cmd -docker run -p 33333:33333 -e SERVICECONTROL_AUTHENTICATION_ENABLED=true -e SERVICECONTROL_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0 -e SERVICECONTROL_AUTHENTICATION_AUDIENCE=api://servicecontrol -e SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID={servicepulse-client-id} -e SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES=["api://servicecontrol/access_as_user"] particular/servicecontrol:latest +docker run -p 33333:33333 -e SERVICECONTROL_AUTHENTICATION_ENABLED=true -e SERVICECONTROL_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0 -e SERVICECONTROL_AUTHENTICATION_AUDIENCE=api://servicecontrol -e SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID={servicepulse-client-id} -e SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0 -e SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES=["api://servicecontrol/access_as_user"] particular/servicecontrol:latest ``` ### Audit and Monitoring Instances @@ -120,6 +124,9 @@ The following endpoints are accessible without authentication, even when authent | Endpoint | Purpose | |-------------------------------------|----------------------------------------------------------------------| | `/api` | API root/discovery - returns available endpoints and API information | +| `/api/instance-info` | Returns instance configuration information | +| `/api/configuration` | Returns instance configuration information (alias) | +| `/api/configuration/remotes` | Returns remote instance configurations for server-to-server fetching | | `/api/authentication/configuration` | Returns authentication configuration for clients like ServicePulse | These endpoints must remain accessible so clients can discover API capabilities and obtain the authentication configuration needed to acquire tokens. diff --git a/docs/forward-headers-testing.md b/docs/forward-headers-testing.md index d2e8521275..cf3045b2e1 100644 --- a/docs/forward-headers-testing.md +++ b/docs/forward-headers-testing.md @@ -9,6 +9,25 @@ This guide explains how to test forwarded headers configuration for ServiceContr - (Optional) For formatted JSON output: `npm install -g json` then pipe curl output through `| json` - All commands assume you are in the respective project directory +## Enabling Debug Logs + +To enable detailed logging for troubleshooting, set the `LogLevel` environment variable before starting each instance: + +```cmd +rem ServiceControl Primary +set SERVICECONTROL_LOGLEVEL=Debug + +rem ServiceControl.Audit +set SERVICECONTROL_AUDIT_LOGLEVEL=Debug + +rem ServiceControl.Monitoring +set MONITORING_LOGLEVEL=Debug +``` + +**Valid log levels:** `Trace`, `Debug`, `Information` (or `Info`), `Warning` (or `Warn`), `Error`, `Critical` (or `Fatal`), `None` (or `Off`) + +Debug logs will show detailed forwarded headers processing and trust evaluation information. + ## Instance Reference | Instance | Project Directory | Default Port | Environment Variable Prefix | diff --git a/docs/hosting-guide.md b/docs/hosting-guide.md index 726ab6a4e4..9b73c75601 100644 --- a/docs/hosting-guide.md +++ b/docs/hosting-guide.md @@ -51,18 +51,18 @@ Configure the hostname and port that each instance listens on: **Environment variables:** -```bash -# ServiceControl Primary -SERVICECONTROL_HOSTNAME=localhost -SERVICECONTROL_PORT=33333 - -# ServiceControl.Audit -SERVICECONTROL_AUDIT_HOSTNAME=localhost -SERVICECONTROL_AUDIT_PORT=44444 - -# ServiceControl.Monitoring -MONITORING_HOSTNAME=localhost -MONITORING_PORT=33633 +```cmd +rem ServiceControl Primary +set SERVICECONTROL_HOSTNAME=localhost +set SERVICECONTROL_PORT=33333 + +rem ServiceControl.Audit +set SERVICECONTROL_AUDIT_HOSTNAME=localhost +set SERVICECONTROL_AUDIT_PORT=44444 + +rem ServiceControl.Monitoring +set MONITORING_HOSTNAME=localhost +set MONITORING_PORT=33633 ``` > **Note:** Use `localhost` or `+` (all interfaces) for the hostname. When behind a reverse proxy, use `localhost` and configure the proxy to forward to the appropriate port. @@ -82,9 +82,9 @@ The Primary instance must be configured to communicate with Audit instances for **Environment variables:** -```bash -# Single Audit instance -SERVICECONTROL_REMOTEINSTANCES=[{"api_uri":"https://servicecontrol-audit:44444/api"}] +```cmd +rem Single Audit instance +set SERVICECONTROL_REMOTEINSTANCES=[{"api_uri":"https://servicecontrol-audit:44444/api"}] ``` For multiple Audit instances: @@ -97,8 +97,8 @@ For multiple Audit instances: **Environment variables:** -```bash -SERVICECONTROL_REMOTEINSTANCES=[{"api_uri":"https://servicecontrol-audit1:44444/api"},{"api_uri":"https://servicecontrol-audit2:44444/api"}] +```cmd +set SERVICECONTROL_REMOTEINSTANCES=[{"api_uri":"https://servicecontrol-audit1:44444/api"},{"api_uri":"https://servicecontrol-audit2:44444/api"}] ``` > **Important:** When authentication is enabled, all instances (Primary, Audit, Monitoring) must use the **same** Identity Provider (IdP) Authority and Audience settings. Client tokens are forwarded to remote instances during scatter-gather operations. @@ -152,6 +152,7 @@ Client → HTTPS → Reverse Proxy → HTTP → ServiceControl + @@ -172,36 +173,37 @@ Client → HTTPS → Reverse Proxy → HTTP → ServiceControl **Environment variables:** -```bash -# Host and Port -SERVICECONTROL_HOSTNAME=localhost -SERVICECONTROL_PORT=33333 - -# Remote Audit Instance(s) -SERVICECONTROL_REMOTEINSTANCES=[{"api_uri":"https://servicecontrol-audit/api"}] - -# Authentication -SERVICECONTROL_AUTHENTICATION_ENABLED=true -SERVICECONTROL_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0 -SERVICECONTROL_AUTHENTICATION_AUDIENCE=api://servicecontrol - -# ServicePulse client configuration (Primary instance only) -SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID={servicepulse-client-id} -SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES=["api://servicecontrol/access_as_user"] - -# Forwarded headers - trust only your reverse proxy -SERVICECONTROL_FORWARDEDHEADERS_ENABLED=true -SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES=false -SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES=10.0.0.5 -# Or use CIDR notation: -# SERVICECONTROL_FORWARDEDHEADERS_KNOWNNETWORKS=10.0.0.0/24 - -# HTTP to HTTPS redirect (optional) -SERVICECONTROL_HTTPS_REDIRECTHTTPTOHTTPS=true -SERVICECONTROL_HTTPS_PORT=443 - -# Restrict CORS -SERVICECONTROL_CORS_ALLOWEDORIGINS=https://servicepulse +```cmd +rem Host and Port +set SERVICECONTROL_HOSTNAME=localhost +set SERVICECONTROL_PORT=33333 + +rem Remote Audit Instance(s) +set SERVICECONTROL_REMOTEINSTANCES=[{"api_uri":"https://servicecontrol-audit/api"}] + +rem Authentication +set SERVICECONTROL_AUTHENTICATION_ENABLED=true +set SERVICECONTROL_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0 +set SERVICECONTROL_AUTHENTICATION_AUDIENCE=api://servicecontrol + +rem ServicePulse client configuration (Primary instance only) +set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID={servicepulse-client-id} +set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0 +set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES=["api://servicecontrol/access_as_user"] + +rem Forwarded headers - trust only your reverse proxy +set SERVICECONTROL_FORWARDEDHEADERS_ENABLED=true +set SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES=false +set SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES=10.0.0.5 +rem Or use CIDR notation: +rem set SERVICECONTROL_FORWARDEDHEADERS_KNOWNNETWORKS=10.0.0.0/24 + +rem HTTP to HTTPS redirect (optional) +set SERVICECONTROL_HTTPS_REDIRECTHTTPTOHTTPS=true +set SERVICECONTROL_HTTPS_PORT=443 + +rem Restrict CORS +set SERVICECONTROL_CORS_ALLOWEDORIGINS=https://servicepulse ``` #### ServiceControl.Audit Configuration @@ -231,23 +233,23 @@ SERVICECONTROL_CORS_ALLOWEDORIGINS=https://servicepulse **Environment variables:** -```bash -# Host and Port -SERVICECONTROL_AUDIT_HOSTNAME=localhost -SERVICECONTROL_AUDIT_PORT=44444 +```cmd +rem Host and Port +set SERVICECONTROL_AUDIT_HOSTNAME=localhost +set SERVICECONTROL_AUDIT_PORT=44444 -# Authentication (same Authority and Audience as Primary) -SERVICECONTROL_AUDIT_AUTHENTICATION_ENABLED=true -SERVICECONTROL_AUDIT_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0 -SERVICECONTROL_AUDIT_AUTHENTICATION_AUDIENCE=api://servicecontrol +rem Authentication (same Authority and Audience as Primary) +set SERVICECONTROL_AUDIT_AUTHENTICATION_ENABLED=true +set SERVICECONTROL_AUDIT_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0 +set SERVICECONTROL_AUDIT_AUTHENTICATION_AUDIENCE=api://servicecontrol -# Forwarded headers -SERVICECONTROL_AUDIT_FORWARDEDHEADERS_ENABLED=true -SERVICECONTROL_AUDIT_FORWARDEDHEADERS_TRUSTALLPROXIES=false -SERVICECONTROL_AUDIT_FORWARDEDHEADERS_KNOWNPROXIES=10.0.0.5 +rem Forwarded headers +set SERVICECONTROL_AUDIT_FORWARDEDHEADERS_ENABLED=true +set SERVICECONTROL_AUDIT_FORWARDEDHEADERS_TRUSTALLPROXIES=false +set SERVICECONTROL_AUDIT_FORWARDEDHEADERS_KNOWNPROXIES=10.0.0.5 -# Restrict CORS -SERVICECONTROL_AUDIT_CORS_ALLOWEDORIGINS=https://servicepulse +rem Restrict CORS +set SERVICECONTROL_AUDIT_CORS_ALLOWEDORIGINS=https://servicepulse ``` #### ServiceControl.Monitoring Configuration @@ -277,23 +279,23 @@ SERVICECONTROL_AUDIT_CORS_ALLOWEDORIGINS=https://servicepulse **Environment variables:** -```bash -# Host and Port -MONITORING_HOSTNAME=localhost -MONITORING_PORT=33633 +```cmd +rem Host and Port +set MONITORING_HOSTNAME=localhost +set MONITORING_PORT=33633 -# Authentication (same Authority and Audience as Primary) -MONITORING_AUTHENTICATION_ENABLED=true -MONITORING_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0 -MONITORING_AUTHENTICATION_AUDIENCE=api://servicecontrol +rem Authentication (same Authority and Audience as Primary) +set MONITORING_AUTHENTICATION_ENABLED=true +set MONITORING_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0 +set MONITORING_AUTHENTICATION_AUDIENCE=api://servicecontrol -# Forwarded headers -MONITORING_FORWARDEDHEADERS_ENABLED=true -MONITORING_FORWARDEDHEADERS_TRUSTALLPROXIES=false -MONITORING_FORWARDEDHEADERS_KNOWNPROXIES=10.0.0.5 +rem Forwarded headers +set MONITORING_FORWARDEDHEADERS_ENABLED=true +set MONITORING_FORWARDEDHEADERS_TRUSTALLPROXIES=false +set MONITORING_FORWARDEDHEADERS_KNOWNPROXIES=10.0.0.5 -# Restrict CORS -MONITORING_CORS_ALLOWEDORIGINS=https://servicepulse +rem Restrict CORS +set MONITORING_CORS_ALLOWEDORIGINS=https://servicepulse ``` --- @@ -349,6 +351,7 @@ Client → HTTPS → ServiceControl (Kestrel) + @@ -361,35 +364,36 @@ Client → HTTPS → ServiceControl (Kestrel) **Environment variables:** -```bash -# Host and Port -SERVICECONTROL_HOSTNAME=servicecontrol -SERVICECONTROL_PORT=33333 +```cmd +rem Host and Port +set SERVICECONTROL_HOSTNAME=servicecontrol +set SERVICECONTROL_PORT=33333 -# Remote Audit Instance(s) -SERVICECONTROL_REMOTEINSTANCES=[{"api_uri":"https://servicecontrol-audit:44444/api"}] +rem Remote Audit Instance(s) +set SERVICECONTROL_REMOTEINSTANCES=[{"api_uri":"https://servicecontrol-audit:44444/api"}] -# Kestrel HTTPS -SERVICECONTROL_HTTPS_ENABLED=true -SERVICECONTROL_HTTPS_CERTIFICATEPATH=/certs/servicecontrol.pfx -SERVICECONTROL_HTTPS_CERTIFICATEPASSWORD=your-certificate-password -SERVICECONTROL_HTTPS_ENABLEHSTS=true -SERVICECONTROL_HTTPS_HSTSMAXAGESECONDS=31536000 +rem Kestrel HTTPS +set SERVICECONTROL_HTTPS_ENABLED=true +set SERVICECONTROL_HTTPS_CERTIFICATEPATH=C:\certs\servicecontrol.pfx +set SERVICECONTROL_HTTPS_CERTIFICATEPASSWORD=your-certificate-password +set SERVICECONTROL_HTTPS_ENABLEHSTS=true +set SERVICECONTROL_HTTPS_HSTSMAXAGESECONDS=31536000 -# Authentication -SERVICECONTROL_AUTHENTICATION_ENABLED=true -SERVICECONTROL_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0 -SERVICECONTROL_AUTHENTICATION_AUDIENCE=api://servicecontrol +rem Authentication +set SERVICECONTROL_AUTHENTICATION_ENABLED=true +set SERVICECONTROL_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0 +set SERVICECONTROL_AUTHENTICATION_AUDIENCE=api://servicecontrol -# ServicePulse client configuration -SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID={servicepulse-client-id} -SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES=["api://servicecontrol/access_as_user"] +rem ServicePulse client configuration +set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID={servicepulse-client-id} +set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0 +set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES=["api://servicecontrol/access_as_user"] -# Restrict CORS -SERVICECONTROL_CORS_ALLOWEDORIGINS=https://servicepulse +rem Restrict CORS +set SERVICECONTROL_CORS_ALLOWEDORIGINS=https://servicepulse -# No forwarded headers (no proxy) -SERVICECONTROL_FORWARDEDHEADERS_ENABLED=false +rem No forwarded headers (no proxy) +set SERVICECONTROL_FORWARDEDHEADERS_ENABLED=false ``` #### Audit Instance Configuration (Direct HTTPS) @@ -423,27 +427,27 @@ SERVICECONTROL_FORWARDEDHEADERS_ENABLED=false **Environment variables:** -```bash -# Host and Port -SERVICECONTROL_AUDIT_HOSTNAME=servicecontrol-audit -SERVICECONTROL_AUDIT_PORT=44444 +```cmd +rem Host and Port +set SERVICECONTROL_AUDIT_HOSTNAME=servicecontrol-audit +set SERVICECONTROL_AUDIT_PORT=44444 -# Kestrel HTTPS -SERVICECONTROL_AUDIT_HTTPS_ENABLED=true -SERVICECONTROL_AUDIT_HTTPS_CERTIFICATEPATH=/certs/servicecontrol-audit.pfx -SERVICECONTROL_AUDIT_HTTPS_CERTIFICATEPASSWORD=your-certificate-password -SERVICECONTROL_AUDIT_HTTPS_ENABLEHSTS=true +rem Kestrel HTTPS +set SERVICECONTROL_AUDIT_HTTPS_ENABLED=true +set SERVICECONTROL_AUDIT_HTTPS_CERTIFICATEPATH=C:\certs\servicecontrol-audit.pfx +set SERVICECONTROL_AUDIT_HTTPS_CERTIFICATEPASSWORD=your-certificate-password +set SERVICECONTROL_AUDIT_HTTPS_ENABLEHSTS=true -# Authentication -SERVICECONTROL_AUDIT_AUTHENTICATION_ENABLED=true -SERVICECONTROL_AUDIT_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0 -SERVICECONTROL_AUDIT_AUTHENTICATION_AUDIENCE=api://servicecontrol +rem Authentication +set SERVICECONTROL_AUDIT_AUTHENTICATION_ENABLED=true +set SERVICECONTROL_AUDIT_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0 +set SERVICECONTROL_AUDIT_AUTHENTICATION_AUDIENCE=api://servicecontrol -# Restrict CORS -SERVICECONTROL_AUDIT_CORS_ALLOWEDORIGINS=https://servicepulse +rem Restrict CORS +set SERVICECONTROL_AUDIT_CORS_ALLOWEDORIGINS=https://servicepulse -# No forwarded headers -SERVICECONTROL_AUDIT_FORWARDEDHEADERS_ENABLED=false +rem No forwarded headers +set SERVICECONTROL_AUDIT_FORWARDEDHEADERS_ENABLED=false ``` #### Monitoring Instance Configuration (Direct HTTPS) @@ -477,27 +481,27 @@ SERVICECONTROL_AUDIT_FORWARDEDHEADERS_ENABLED=false **Environment variables:** -```bash -# Host and Port -MONITORING_HOSTNAME=servicecontrol-monitoring -MONITORING_PORT=33633 +```cmd +rem Host and Port +set MONITORING_HOSTNAME=servicecontrol-monitoring +set MONITORING_PORT=33633 -# Kestrel HTTPS -MONITORING_HTTPS_ENABLED=true -MONITORING_HTTPS_CERTIFICATEPATH=/certs/servicecontrol-monitoring.pfx -MONITORING_HTTPS_CERTIFICATEPASSWORD=your-certificate-password -MONITORING_HTTPS_ENABLEHSTS=true +rem Kestrel HTTPS +set MONITORING_HTTPS_ENABLED=true +set MONITORING_HTTPS_CERTIFICATEPATH=C:\certs\servicecontrol-monitoring.pfx +set MONITORING_HTTPS_CERTIFICATEPASSWORD=your-certificate-password +set MONITORING_HTTPS_ENABLEHSTS=true -# Authentication -MONITORING_AUTHENTICATION_ENABLED=true -MONITORING_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0 -MONITORING_AUTHENTICATION_AUDIENCE=api://servicecontrol +rem Authentication +set MONITORING_AUTHENTICATION_ENABLED=true +set MONITORING_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0 +set MONITORING_AUTHENTICATION_AUDIENCE=api://servicecontrol -# Restrict CORS -MONITORING_CORS_ALLOWEDORIGINS=https://servicepulse +rem Restrict CORS +set MONITORING_CORS_ALLOWEDORIGINS=https://servicepulse -# No forwarded headers -MONITORING_FORWARDEDHEADERS_ENABLED=false +rem No forwarded headers +set MONITORING_FORWARDEDHEADERS_ENABLED=false ``` --- @@ -553,6 +557,7 @@ Client → HTTPS → Reverse Proxy → HTTPS → ServiceControl (Kestrel) + @@ -567,35 +572,36 @@ Client → HTTPS → Reverse Proxy → HTTPS → ServiceControl (Kestrel) **Environment variables:** -```bash -# Host and Port -SERVICECONTROL_HOSTNAME=localhost -SERVICECONTROL_PORT=33333 +```cmd +rem Host and Port +set SERVICECONTROL_HOSTNAME=localhost +set SERVICECONTROL_PORT=33333 -# Remote Audit Instance(s) -SERVICECONTROL_REMOTEINSTANCES=[{"api_uri":"https://servicecontrol-audit/api"}] +rem Remote Audit Instance(s) +set SERVICECONTROL_REMOTEINSTANCES=[{"api_uri":"https://servicecontrol-audit/api"}] -# Kestrel HTTPS for internal encryption -SERVICECONTROL_HTTPS_ENABLED=true -SERVICECONTROL_HTTPS_CERTIFICATEPATH=/certs/servicecontrol-internal.pfx -SERVICECONTROL_HTTPS_CERTIFICATEPASSWORD=your-certificate-password +rem Kestrel HTTPS for internal encryption +set SERVICECONTROL_HTTPS_ENABLED=true +set SERVICECONTROL_HTTPS_CERTIFICATEPATH=C:\certs\servicecontrol-internal.pfx +set SERVICECONTROL_HTTPS_CERTIFICATEPASSWORD=your-certificate-password -# Authentication -SERVICECONTROL_AUTHENTICATION_ENABLED=true -SERVICECONTROL_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0 -SERVICECONTROL_AUTHENTICATION_AUDIENCE=api://servicecontrol +rem Authentication +set SERVICECONTROL_AUTHENTICATION_ENABLED=true +set SERVICECONTROL_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0 +set SERVICECONTROL_AUTHENTICATION_AUDIENCE=api://servicecontrol -# ServicePulse client configuration -SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID={servicepulse-client-id} -SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES=["api://servicecontrol/access_as_user"] +rem ServicePulse client configuration +set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID={servicepulse-client-id} +set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0 +set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES=["api://servicecontrol/access_as_user"] -# Forwarded headers - trust only your reverse proxy -SERVICECONTROL_FORWARDEDHEADERS_ENABLED=true -SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES=false -SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES=10.0.0.5 +rem Forwarded headers - trust only your reverse proxy +set SERVICECONTROL_FORWARDEDHEADERS_ENABLED=true +set SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES=false +set SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES=10.0.0.5 -# Restrict CORS -SERVICECONTROL_CORS_ALLOWEDORIGINS=https://servicepulse +rem Restrict CORS +set SERVICECONTROL_CORS_ALLOWEDORIGINS=https://servicepulse ``` > **Note:** Audit and Monitoring instances follow the same pattern. See Scenario 1 for the authentication and forwarded headers configuration, and add the HTTPS settings as shown above. diff --git a/docs/https-testing.md b/docs/https-testing.md index 69bab0e7e4..9e57ca5484 100644 --- a/docs/https-testing.md +++ b/docs/https-testing.md @@ -20,6 +20,25 @@ This guide provides scenario-based tests for ServiceControl's direct HTTPS featu - ServiceControl built locally (see main README for build instructions) - curl (included with Windows 10/11, Git Bash, or WSL) +## Enabling Debug Logs + +To enable detailed logging for troubleshooting, set the `LogLevel` environment variable before starting each instance: + +```cmd +rem ServiceControl Primary +set SERVICECONTROL_LOGLEVEL=Debug + +rem ServiceControl.Audit +set SERVICECONTROL_AUDIT_LOGLEVEL=Debug + +rem ServiceControl.Monitoring +set MONITORING_LOGLEVEL=Debug +``` + +**Valid log levels:** `Trace`, `Debug`, `Information` (or `Info`), `Warning` (or `Warn`), `Error`, `Critical` (or `Fatal`), `None` (or `Off`) + +Debug logs will show detailed HTTPS configuration and certificate loading information. + ### Installing mkcert **Windows (using Chocolatey):** @@ -76,7 +95,7 @@ mkcert -install cd .local/certs # Generate PFX certificate for localhost -mkcert -p12-file localhost.pfx -pkcs12 localhost 127.0.0.1 ::1 +mkcert -p12-file localhost.pfx -pkcs12 localhost 127.0.0.1 ::1 servicecontrol servicecontrol-audit servicecontrol-monitor ``` When prompted for a password, you can use an empty password by pressing Enter, or set a password (e.g., `changeit`) and note it for the configuration step. diff --git a/docs/reverseproxy-testing.md b/docs/reverseproxy-testing.md index e3ce5a92fb..121b4b8809 100644 --- a/docs/reverseproxy-testing.md +++ b/docs/reverseproxy-testing.md @@ -23,6 +23,25 @@ This guide provides scenario-based tests for ServiceControl instances behind an - ServiceControl built locally (see main README for build instructions) - curl (included with Windows 10/11, Git Bash, or WSL) +## Enabling Debug Logs + +To enable detailed logging for troubleshooting, set the `LogLevel` environment variable before starting each instance: + +```cmd +rem ServiceControl Primary +set SERVICECONTROL_LOGLEVEL=Debug + +rem ServiceControl.Audit +set SERVICECONTROL_AUDIT_LOGLEVEL=Debug + +rem ServiceControl.Monitoring +set MONITORING_LOGLEVEL=Debug +``` + +**Valid log levels:** `Trace`, `Debug`, `Information` (or `Info`), `Warning` (or `Warn`), `Error`, `Critical` (or `Fatal`), `None` (or `Off`) + +Debug logs will show detailed request processing information including forwarded headers handling and HTTPS redirection. + ### Installing mkcert **Windows (using Chocolatey):** diff --git a/src/ServiceControl.Audit/Infrastructure/WebApi/RootController.cs b/src/ServiceControl.Audit/Infrastructure/WebApi/RootController.cs index 36ce8d8363..376d6f638b 100644 --- a/src/ServiceControl.Audit/Infrastructure/WebApi/RootController.cs +++ b/src/ServiceControl.Audit/Infrastructure/WebApi/RootController.cs @@ -45,6 +45,7 @@ public OkObjectResult Urls() [Route("instance-info")] [Route("configuration")] [HttpGet] + [AllowAnonymous] public OkObjectResult Config() { object content = new diff --git a/src/ServiceControl/Infrastructure/WebApi/RootController.cs b/src/ServiceControl/Infrastructure/WebApi/RootController.cs index b7500d44d7..c29d128939 100644 --- a/src/ServiceControl/Infrastructure/WebApi/RootController.cs +++ b/src/ServiceControl/Infrastructure/WebApi/RootController.cs @@ -8,13 +8,14 @@ using ServiceControl.Api; using ServiceControl.Api.Contracts; + // Anonymous access is required for server-to-server remote configuration fetching + [AllowAnonymous] [ApiController] [Route("api")] public class RootController(IConfigurationApi configurationApi) : ControllerBase { [Route("")] [HttpGet] - [AllowAnonymous] public Task Urls() => configurationApi.GetUrls(Request.GetDisplayUrl(), default); [Route("instance-info")]