diff --git a/.aspire/settings.json b/.aspire/settings.json new file mode 100644 index 0000000000..bbd7ab5c9b --- /dev/null +++ b/.aspire/settings.json @@ -0,0 +1,3 @@ +{ + "appHostPath": "../AppHost/AppHost.csproj" +} \ No newline at end of file diff --git a/AppHost/AppHost.cs b/AppHost/AppHost.cs new file mode 100644 index 0000000000..a0cc3990f2 --- /dev/null +++ b/AppHost/AppHost.cs @@ -0,0 +1,59 @@ +using Bit.AppHost; + +var builder = DistributedApplication.CreateBuilder(args); +var secretsSetup = builder.ConfigureSecrets(); +var isSelfHosted = builder.Configuration["globalSettings:selfHosted"]?.ToLowerInvariant() == "true"; + +// Add Pricing Service - use port from pricingUri in secrets +var pricingService = + builder + .AddProject("pricing-service", + builder.Configuration["pricingServiceRelativePath"] + ?? throw new ArgumentNullException("pricingServiceRelativePath", "Missing pricing service relative path")); + +// Add Database and run migrations +var db = builder.AddSqlServerDatabaseResource(isSelfHosted); +builder.ConfigureMigrations(isSelfHosted) + .WaitFor(db) + .ExcludeFromManifest() + .WaitForCompletion(secretsSetup); + +var azurite = builder.ConfigureAzurite(); + +// Add MailCatcher +var mail = builder + .AddContainer("mailcatcher", "sj26/mailcatcher:latest") + .WithLifetime(ContainerLifetime.Persistent) + .WithEndpoint(port: 10250, name: "smtp", targetPort: 1025) // SMTP port + .WithHttpEndpoint(port: 1080, name: "web", targetPort: 1080); + + +// Add Services +builder.AddBitwardenService(db, secretsSetup, mail, "admin"); +var api = builder.AddBitwardenService(db, secretsSetup, mail, "api") + .WithReference(pricingService) + .WaitFor(azurite); +var billing = builder.AddBitwardenService(db, secretsSetup, mail, "billing"); +builder.AddBitwardenService(db, secretsSetup, mail, "identity"); +builder.AddBitwardenService(db, secretsSetup, mail, "notifications") + .WaitFor(azurite); + +// Add Client Apps +builder.AddBitwardenNpmApp("web-frontend", "web", api) + .WithHttpsEndpoint(8080, 8080, "angular-http", isProxied: false) + .WithUrl("https://bitwarden.test:8080") + .WithExternalHttpEndpoints(); +builder.AddBitwardenNpmApp("desktop-frontend", "desktop", api, "start"); +builder.AddBitwardenNpmApp("browser-frontend", "browser", api, "build:bit:watch:chrome"); + +// Add Ngrok +builder.ConfigureNgrok((billing, "billing-http")); + +builder.Build().Run(); + + + + + + + diff --git a/AppHost/AppHost.csproj b/AppHost/AppHost.csproj new file mode 100644 index 0000000000..ea03a3bed1 --- /dev/null +++ b/AppHost/AppHost.csproj @@ -0,0 +1,29 @@ + + + + + + Exe + net8.0 + enable + enable + e0dba0c6-d131-43bd-9143-2260f11a14ad + + + + + + + + + + + + + + + + + + + diff --git a/AppHost/BuilderExtensions.cs b/AppHost/BuilderExtensions.cs new file mode 100644 index 0000000000..76239e7dc1 --- /dev/null +++ b/AppHost/BuilderExtensions.cs @@ -0,0 +1,156 @@ +using Aspire.Hosting.Azure; +using Azure.Provisioning; +using Azure.Provisioning.Storage; + +namespace Bit.AppHost; + +public static class BuilderExtensions +{ + public static IResourceBuilder ConfigureSecrets(this IDistributedApplicationBuilder builder) + { + // Setup secrets before starting services + var secretsScript = builder.Configuration["scripts:secretsSetup"] ?? throw new ArgumentNullException("setupSecretsScriptPath", "Missing setup secrets script path"); + var pricingSecretsPath = builder.Configuration["pricingServiceSecretsPath"] ?? throw new ArgumentNullException("pricingServiceSecretsPath", "Missing secrets path"); + + //Pricing Secrets + builder + .AddExecutable("pricing-setup-secrets", "pwsh", pricingSecretsPath, "-File", secretsScript, "-clear") + .ExcludeFromManifest(); + return builder + .AddExecutable("setup-secrets", "pwsh", "../dev", "-File", secretsScript, "-clear") + .ExcludeFromManifest(); + } + + public static IResourceBuilder AddSqlServerDatabaseResource(this IDistributedApplicationBuilder builder, bool isSelfHosted = false) + { + var password = isSelfHosted + ? builder.Configuration["dev:selfHostOverride:globalSettings:sqlServer:password"] + : builder.Configuration["globalSettings:sqlServer:password"]; + + // Add MSSQL - retrieve password from connection string in secrets + var dbpassword = builder.AddParameter("dbPassword", password!, secret: true); + return builder + .AddSqlServer("mssql", password: dbpassword, 1433) + .WithImage("mssql/server:2022-latest") + .WithLifetime(ContainerLifetime.Persistent) + .WithDataVolume() + .AddDatabase("vault", isSelfHosted ? "self_host_dev" : "vault_dev"); + } + + public static IResourceBuilder ConfigureAzurite(this IDistributedApplicationBuilder builder) + { + + // https://github.com/dotnet/aspire/discussions/5552 + var azurite = builder + .AddAzureStorage("azurite").ConfigureInfrastructure(c => + { + var blobStorage = c.GetProvisionableResources().OfType().Single(); + blobStorage.CorsRules.Add(new BicepValue(new StorageCorsRule + { + AllowedOrigins = [new BicepValue("*")], + AllowedMethods = [CorsRuleAllowedMethod.Get, CorsRuleAllowedMethod.Put], + AllowedHeaders = [new BicepValue("*")], + ExposedHeaders = [new BicepValue("*")], + MaxAgeInSeconds = new BicepValue("30") + })); + }) + .RunAsEmulator(c => + { + c.WithBlobPort(10000). + WithQueuePort(10001). + WithTablePort(10002); + }); + + var workingDirectory = builder.Configuration["workingDirectory"] ?? throw new ArgumentNullException("workingDirectory", "Missing working directory"); + + //Run Azurite setup + var azuriteSetupScript = + builder + .Configuration["scripts:azuriteSetup"] + ?? throw new ArgumentNullException("azuriteSetupScriptPath", "Missing azurite setup script path"); + + builder + .AddExecutable("azurite-setup", "pwsh", workingDirectory, "-File", azuriteSetupScript) + .WaitFor(azurite) + .ExcludeFromManifest(); + return azurite; + } + + public static IResourceBuilder ConfigureNgrok(this IDistributedApplicationBuilder builder, (IResourceBuilder, string) tunnelResource) + { + var authToken = builder + .AddParameter("ngrok-auth-token", + builder.Configuration["ngrokAuthToken"] + ?? throw new ArgumentNullException("ngrokAuthToken", "Missing ngrok auth token"), + secret: true); + + return builder.AddNgrok("billing-webhook-ngrok-endpoint", endpointPort: 59600) + .WithAuthToken(authToken) + .WithTunnelEndpoint(tunnelResource.Item1, tunnelResource.Item2) + .WithExplicitStart(); + } + + public static IResourceBuilder ConfigureMigrations(this IDistributedApplicationBuilder builder, bool isSelfHosted) + { + var workingDirectory = builder.Configuration["workingDirectory"] ?? + throw new ArgumentNullException("workingDirectory", "Missing working directory"); + var migrationArgs = new List + { + "-File", + builder.Configuration["scripts:dbMigration"] + ?? throw new ArgumentNullException("migrationScriptPath", "Missing migration script path") + }; + if (isSelfHosted) + { + migrationArgs.Add("-self-hosted"); + } + + return builder + .AddExecutable("run-db-migrations", "pwsh", workingDirectory, migrationArgs.ToArray()); + } + + public static IResourceBuilder AddBitwardenService( + this IDistributedApplicationBuilder builder, IResourceBuilder db, + IResourceBuilder secretsSetup, IResourceBuilder mail, string name) + where TProject : IProjectMetadata, new() + { + var service = builder.AddProject(name) + .WithHttpEndpoint(port: builder.GetBitwardenServicePort(name), name: $"{name}-http") + .WithReference(db) + .WaitFor(db) + .WaitForCompletion(secretsSetup); + + if (name is "admin" or "identity" or "billing") + { + service.WithReference(mail.GetEndpoint("smtp")); + } + + return service; + } + + public static IResourceBuilder AddBitwardenNpmApp(this IDistributedApplicationBuilder builder, + string name, string path, IResourceBuilder api, string scriptName = "build:bit:watch") + { + var clientsRelativePath = builder.Configuration["clientsRelativePath"] ?? + throw new ArgumentNullException("clientsRelativePath", "Missing client relative path"); + + return builder + .AddNpmApp(name, $"{clientsRelativePath}/{path}", scriptName) + .WithReference(api) + .WaitFor(api) + .WithExplicitStart(); + } + + public static int GetBitwardenServicePort(this IDistributedApplicationBuilder builder, string serviceName) + { + var isSelfHosted = builder.Configuration["isSelfHosted"] == "true"; + var configKey = isSelfHosted + ? $"dev:selfHostOverride:globalSettings:baseServiceUri:{serviceName}" + : $"globalSettings:baseServiceUri:{serviceName}"; + + var uriString = builder.Configuration[configKey] + ?? throw new InvalidOperationException($"Configuration value for '{configKey}' not found."); + + return new Uri(uriString).Port; + } +} diff --git a/AppHost/Properties/launchSettings.json b/AppHost/Properties/launchSettings.json new file mode 100644 index 0000000000..14f58c388d --- /dev/null +++ b/AppHost/Properties/launchSettings.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17271;http://localhost:15055", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21022", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22177" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15055", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19147", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20252" + } + } + } +} diff --git a/AppHost/appsettings.Development.json b/AppHost/appsettings.Development.json new file mode 100644 index 0000000000..0c208ae918 --- /dev/null +++ b/AppHost/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/AppHost/appsettings.json b/AppHost/appsettings.json new file mode 100644 index 0000000000..31c092aa45 --- /dev/null +++ b/AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/ServiceDefaults/Extensions.cs b/ServiceDefaults/Extensions.cs new file mode 100644 index 0000000000..8e97f0c178 --- /dev/null +++ b/ServiceDefaults/Extensions.cs @@ -0,0 +1,197 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +namespace Bit.ServiceDefaults; + +// Adds common .NET Aspire services: service discovery, resilience, health checks, and OpenTelemetry. +// This project should be referenced by each service project in your solution. +// To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults +public static class Extensions +{ + private const string HealthEndpointPath = "/health"; + private const string AlivenessEndpointPath = "/alive"; + + public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.ConfigureOpenTelemetry(); + + builder.AddDefaultHealthChecks(); + + builder.Services.AddServiceDiscovery(); + + builder.Services.ConfigureHttpClientDefaults(http => + { + // Turn on resilience by default + http.AddStandardResilienceHandler(); + + // Turn on service discovery by default + http.AddServiceDiscovery(); + }); + + // Uncomment the following to restrict the allowed schemes for service discovery. + // builder.Services.Configure(options => + // { + // options.AllowedSchemes = ["https"]; + // }); + + return builder; + } + + public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation(); + }) + .WithTracing(tracing => + { + tracing.AddSource(builder.Environment.ApplicationName) + .AddAspNetCoreInstrumentation(tracing => + // Exclude health check requests from tracing + tracing.Filter = context => + !context.Request.Path.StartsWithSegments(HealthEndpointPath) + && !context.Request.Path.StartsWithSegments(AlivenessEndpointPath) + ) + // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) + //.AddGrpcClientInstrumentation() + .AddHttpClientInstrumentation(); + }); + + builder.AddOpenTelemetryExporters(); + + return builder; + } + + private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + + if (useOtlpExporter) + { + builder.Services.AddOpenTelemetry().UseOtlpExporter(); + } + + // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package) + //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"])) + //{ + // builder.Services.AddOpenTelemetry() + // .UseAzureMonitor(); + //} + + return builder; + } + + public static TBuilder AddDefaultHealthChecks(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Services.AddHealthChecks() + // Add a default liveness check to ensure app is responsive + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + return builder; + } + + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + // Adding health checks endpoints to applications in non-development environments has security implications. + // See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments. + if (app.Environment.IsDevelopment()) + { + // All health checks must pass for app to be considered ready to accept traffic after starting + app.MapHealthChecks(HealthEndpointPath); + + // Only health checks tagged with the "live" tag must pass for app to be considered alive + app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("live") + }); + } + + return app; + } + + // Overload for IHostBuilder + public static IHostBuilder AddServiceDefaults(this IHostBuilder hostBuilder) + { + hostBuilder.ConfigureServices((context, services) => + { + if (context.HostingEnvironment.IsDevelopment()) + { + services.AddHealthChecks() + .AddCheck("self", () => HealthCheckResult.Healthy(), new[] { "live" }); + } + + services.AddServiceDiscovery(); + services.ConfigureHttpClientDefaults(http => + { + http.AddStandardResilienceHandler(); + http.AddServiceDiscovery(); + }); + services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation(); + }) + .WithTracing(tracing => + { + tracing.AddSource(context.HostingEnvironment.ApplicationName) + .AddAspNetCoreInstrumentation(tracing => + tracing.Filter = context => + !context.Request.Path.StartsWithSegments(HealthEndpointPath) + && !context.Request.Path.StartsWithSegments(AlivenessEndpointPath) + ) + .AddHttpClientInstrumentation(httpClient => + { + httpClient.EnrichWithHttpRequestMessage = (activity, message) => + { + if (context.HostingEnvironment.IsDevelopment()) + { + activity.SetTag("http.request_content_length", message.Content?.Headers.ContentLength); + activity.SetTag("http.request_method", message.Method.Method); + activity.SetTag("http.request_url", message.RequestUri?.ToString()); + activity.SetTag("http.request_message_headers", message.Headers.ToString()); + activity.SetTag("http.request_body", message.Content?.ReadAsStringAsync().Result); + } + }; + httpClient.EnrichWithHttpResponseMessage = (activity, message) => + { + if (context.HostingEnvironment.IsDevelopment()) + { + activity.SetTag("http.response_content_length", + message.Content.Headers.ContentLength); + activity.SetTag("http.response_status_code", (int)message.StatusCode); + activity.SetTag("http.response_status_text", message.ReasonPhrase); + activity.SetTag("http.response_content_type", + message.Content.Headers.ContentType?.MediaType); + activity.SetTag("http.response_message_headers", message.Headers.ToString()); + activity.SetTag("http.response_body", message.Content.ReadAsStringAsync().Result); + } + }; + }); + }); + var useOtlpExporter = !string.IsNullOrWhiteSpace(context.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + if (useOtlpExporter) + { + services.AddOpenTelemetry().UseOtlpExporter(); + } + }); + return hostBuilder; + } +} diff --git a/ServiceDefaults/ServiceDefaults.csproj b/ServiceDefaults/ServiceDefaults.csproj new file mode 100644 index 0000000000..12b4707e9c --- /dev/null +++ b/ServiceDefaults/ServiceDefaults.csproj @@ -0,0 +1,22 @@ + + + + net8.0 + enable + enable + true + + + + + + + + + + + + + + + diff --git a/bitwarden-server.sln b/bitwarden-server.sln index 6786ad610c..ba5e838ae4 100644 --- a/bitwarden-server.sln +++ b/bitwarden-server.sln @@ -134,10 +134,15 @@ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DbSeederUtility", "util\DbSeederUtility\DbSeederUtility.csproj", "{17A89266-260A-4A03-81AE-C0468C6EE06E}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RustSdk", "util\RustSdk\RustSdk.csproj", "{D1513D90-E4F5-44A9-9121-5E46E3E4A3F7}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharedWeb.Test", "test\SharedWeb.Test\SharedWeb.Test.csproj", "{AD59537D-5259-4B7A-948F-0CF58E80B359}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SSO.Test", "bitwarden_license\test\SSO.Test\SSO.Test.csproj", "{7D98784C-C253-43FB-9873-25B65C6250D6}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AppHost", "AppHost\AppHost.csproj", "{B4CC25D0-BD09-459E-9885-DF9A56E304F6}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ServiceDefaults", "ServiceDefaults\ServiceDefaults.csproj", "{7D6F3351-9CA6-4B35-956F-1EE346330A41}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -354,6 +359,14 @@ Global {7D98784C-C253-43FB-9873-25B65C6250D6}.Debug|Any CPU.Build.0 = Debug|Any CPU {7D98784C-C253-43FB-9873-25B65C6250D6}.Release|Any CPU.ActiveCfg = Release|Any CPU {7D98784C-C253-43FB-9873-25B65C6250D6}.Release|Any CPU.Build.0 = Release|Any CPU + {B4CC25D0-BD09-459E-9885-DF9A56E304F6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B4CC25D0-BD09-459E-9885-DF9A56E304F6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B4CC25D0-BD09-459E-9885-DF9A56E304F6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B4CC25D0-BD09-459E-9885-DF9A56E304F6}.Release|Any CPU.Build.0 = Release|Any CPU + {7D6F3351-9CA6-4B35-956F-1EE346330A41}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7D6F3351-9CA6-4B35-956F-1EE346330A41}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7D6F3351-9CA6-4B35-956F-1EE346330A41}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7D6F3351-9CA6-4B35-956F-1EE346330A41}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/dev/setup_secrets.ps1 b/dev/setup_secrets.ps1 index 96dff04632..d570a74e46 100644 --- a/dev/setup_secrets.ps1 +++ b/dev/setup_secrets.ps1 @@ -27,6 +27,7 @@ $projects = @{ Sso = "../bitwarden_license/src/Sso" Scim = "../bitwarden_license/src/Scim" IntegrationTests = "../test/Infrastructure.IntegrationTest" + AppHost = "../AppHost" } foreach ($key in $projects.keys) {