diff --git a/.aspire/settings.json b/.aspire/settings.json new file mode 100644 index 000000000000..bbd7ab5c9b3e --- /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 000000000000..e771e06dd023 --- /dev/null +++ b/AppHost/AppHost.cs @@ -0,0 +1,22 @@ +using Bit.AppHost; + +var builder = DistributedApplication.CreateBuilder(args); +var secretsSetup = builder.ConfigureSecrets(); +var db = builder.AddSqlServerDatabaseResource(); +builder.ConfigureMigrations() + .WaitFor(db) + .ExcludeFromManifest() + .WaitForCompletion(secretsSetup); +var azurite = builder.ConfigureAzurite(); +var mail = builder.ConfigureMailCatcher(); +var (_, api, billing, _, _) = builder.ConfigureServices(db, secretsSetup, mail, azurite); + +#if ENABLE_NODEJS_COMMUNITY_PLUGIN +builder.ConfigureWebFrontend(api); +#endif + +#if ENABLE_NGROK_COMMUNITY_PLUGIN +builder.ConfigureNgrok((billing, "billing-http")); +#endif + +builder.Build().Run(); diff --git a/AppHost/AppHost.csproj b/AppHost/AppHost.csproj new file mode 100644 index 000000000000..dc173966e975 --- /dev/null +++ b/AppHost/AppHost.csproj @@ -0,0 +1,41 @@ + + + + Exe + net8.0 + enable + enable + e0dba0c6-d131-43bd-9143-2260f11a14ad + + + + + false + $(DefineConstants);ENABLE_NGROK_COMMUNITY_PLUGIN + false + $(DefineConstants);ENABLE_NODEJS_COMMUNITY_PLUGIN + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/AppHost/BuilderExtensions.cs b/AppHost/BuilderExtensions.cs new file mode 100644 index 000000000000..7fbd16574e9a --- /dev/null +++ b/AppHost/BuilderExtensions.cs @@ -0,0 +1,261 @@ +using Aspire.Hosting.Azure; +using Azure.Provisioning; +using Azure.Provisioning.Storage; + +namespace Bit.AppHost; + +public static class BuilderExtensions +{ + /// + /// Configures the secrets setup executable resource. + /// + /// The distributed application builder used to configure the secrets setup resource. + /// >The configured resource builder for the secrets setup executable. + public static IResourceBuilder ConfigureSecrets(this IDistributedApplicationBuilder builder) + { + return builder + .AddExecutable("setup-secrets", "pwsh", "../dev", "-File", builder.Required("Scripts:SecretsSetup"), + "-clear") + .ExcludeFromManifest(); + } + + /// + /// Configures the migrations executable resource. + /// + /// The distributed application builder used to configure the migrations resource. + /// >The configured resource builder for the migrations executable. + public static IResourceBuilder ConfigureMigrations(this IDistributedApplicationBuilder builder) + { + var migrationArgs = new List { "-File", builder.Required("Scripts:DbMigration") }; + if (builder.IsSelfHosted()) + migrationArgs.Add("-self-hosted"); + + return builder + .AddExecutable("run-db-migrations", "pwsh", builder.Required("WorkingDirectory"), migrationArgs.ToArray()); + } + + public static IResourceBuilder AddSqlServerDatabaseResource( + this IDistributedApplicationBuilder builder) + { + var isSelfHosted = builder.IsSelfHosted(); + var passwordKey = isSelfHosted ? "Database:SelfHostPassword" : "Database:Password"; + if (!int.TryParse(builder.Required("Database:Port"), out var dbPort)) + throw new InvalidOperationException("Invalid value for Database:Port."); + var dbPassword = builder.AddParameter("dbPassword", builder.Required(passwordKey), secret: true); + return builder + .AddSqlServer("mssql", password: dbPassword, dbPort) + .WithImage(builder.Required("Database:Image")) + .WithLifetime(ContainerLifetime.Persistent) + .WithDataVolume() + .AddDatabase("vault-db", isSelfHosted ? "self_host_dev" : "vault_dev"); + } + + public static IResourceBuilder ConfigureAzurite(this IDistributedApplicationBuilder builder) + { + // For more information about this configuration: 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); + }); + + builder + .AddExecutable("azurite-setup", "pwsh", builder.Required("WorkingDirectory"), "-File", + builder.Required("Scripts:AzuriteSetup")) + .WaitFor(azurite) + .ExcludeFromManifest(); + return azurite; + } + + public static IResourceBuilder ConfigureMailCatcher(this IDistributedApplicationBuilder builder) + { + var image = builder.Required("MailCatcher:Image"); + var imageParts = image.Split(':'); + var imageName = imageParts[0]; + var imageTag = imageParts.Length > 1 ? imageParts[1] : "latest"; + + if (!int.TryParse(builder.Required("MailCatcher:SmtpPort"), out var smtpPort)) + throw new InvalidOperationException("Invalid value for MailCatcher:SmtpPort."); + if (!int.TryParse(builder.Required("MailCatcher:WebPort"), out var webPort)) + throw new InvalidOperationException("Invalid value for MailCatcher:WebPort."); + + return builder + .AddContainer("mailcatcher", imageName, imageTag) + .WithLifetime(ContainerLifetime.Persistent) + .WithEndpoint(port: smtpPort, name: "smtp", targetPort: 1025) + .WithHttpEndpoint(port: webPort, name: "web", targetPort: webPort); + } + + /// + /// Configures and initializes the essential services required for the distributed application, + /// including project-specific services such as admin, API, billing, identity, and notifications. + /// + /// The distributed application builder used to configure resources and services. + /// The SQL Server database resource builder. + /// The executable resource builder for configuring secrets. + /// The container resource builder for setting up the mail service. + /// The Azure Storage resource builder used to configure Azurite storage services. + /// A tuple containing resource builders for the admin, API, billing, identity, and notifications projects. + public static ( + IResourceBuilder admin, + IResourceBuilder api, + IResourceBuilder billing, + IResourceBuilder identity, + IResourceBuilder notifications + ) ConfigureServices( + this IDistributedApplicationBuilder builder, + IResourceBuilder db, + IResourceBuilder secretsSetup, + IResourceBuilder mail, + IResourceBuilder azurite) + { + var admin = builder.AddBitwardenService(db, secretsSetup, mail, "admin"); + var api = builder.AddBitwardenService(db, secretsSetup, mail, "api") + .WaitFor(azurite); + var billing = builder.AddBitwardenService(db, secretsSetup, mail, "billing"); + var identity = builder.AddBitwardenService(db, secretsSetup, mail, "identity"); + var notifications = builder.AddBitwardenService(db, secretsSetup, mail, "notifications") + .WaitFor(azurite); + builder.ConfigureAdditionalProjects(new Dictionary> + { + ["admin"] = admin, + ["api"] = api, + ["billing"] = billing, + ["identity"] = identity, + ["notifications"] = notifications + }); + return (admin, api, billing, identity, notifications); + } + + /// + /// Configures additional projects specified in the configuration under "AdditionalProjects". + /// This allows for dynamic inclusion of projects without code changes, useful for testing or temporary additions. + /// + /// The distributed application builder used to access configuration and add project resources. + /// All registered services keyed by name; each additional project's ReferencedBy list selects which ones receive a reference. + private static void ConfigureAdditionalProjects(this IDistributedApplicationBuilder builder, + IReadOnlyDictionary> services) + { + // Add via user-secrets: dotnet user-secrets set "AdditionalProjects::Path" "" + foreach (var section in builder.Configuration.GetSection("AdditionalProjects").GetChildren()) + { + var path = section["Path"]; + if (string.IsNullOrWhiteSpace(path)) + continue; + + var project = builder.AddProject(section.Key, path); + var referencedBy = section.GetSection("ReferencedBy").GetChildren().Select(c => c.Value).ToHashSet(); + foreach (var (_, service) in services.Where(s => referencedBy.Contains(s.Key))) + service.WithReference(project); + } + } + + /// + /// Adds and configures a Bitwarden service of the specified project type. This includes linking the service to + /// necessary resources such as a database, secrets setup, and optionally a mail service based on the project's name. + /// + /// The distributed application builder used to configure the service. + /// The SQL Server database resource to link to the service. + /// The executable resource responsible for secrets setup. + /// The container resource representing the mail service, used conditionally for specific projects. + /// The unique name of the Bitwarden service being added. + /// The type of project implementing the interface. + /// The configured resource builder for the Bitwarden project resource. + private static IResourceBuilder AddBitwardenService( + this IDistributedApplicationBuilder builder, IResourceBuilder db, + IResourceBuilder secretsSetup, IResourceBuilder mail, string name) + where TProject : IProjectMetadata, new() + { + // launchSettings provide the ports for the services + var service = builder.AddProject(name) + .WithEndpoint("http", e => e.Port = builder.GetBitwardenServicePort(name)) + .WithReference(db) + .WaitFor(db) + .WaitForCompletion(secretsSetup); + + if (name is "admin" or "identity" or "billing") + service.WithReference(mail.GetEndpoint("smtp")); + + return service; + } + + private static int GetBitwardenServicePort(this IDistributedApplicationBuilder builder, string serviceName) + { + if (!int.TryParse(builder.Required($"Services:{serviceName}:BasePort"), out var basePort)) + throw new InvalidOperationException($"Invalid port value for Services:{serviceName}:BasePort."); + return builder.IsSelfHosted() ? basePort + 1 : basePort; + } + + /// + /// Retrieves a required configuration value and throws an exception if it's missing. + /// + /// An instance of . + /// The configuration key to retrieve. + /// The configuration value associated with the specified key. + /// + private static string Required(this IDistributedApplicationBuilder builder, string key) => + builder.Configuration[key] ?? throw new InvalidOperationException($"Missing required configuration: {key}"); + + /// + /// Determines if the application is running in self-hosted mode. + /// + /// An instance of . + /// True if the application is self-hosted, otherwise false. + private static bool IsSelfHosted(this IDistributedApplicationBuilder builder) => + builder.Configuration["SelfHost"]?.Equals("true", StringComparison.OrdinalIgnoreCase) == true; + +#if ENABLE_NODEJS_COMMUNITY_PLUGIN + public static void ConfigureWebFrontend(this IDistributedApplicationBuilder builder, + IResourceBuilder api) + { + if (!int.TryParse(builder.Required("WebFrontend:Port"), out var port)) + throw new InvalidOperationException("Invalid value for WebFrontend:Port."); + + builder + .AddBitwardenNpmApp("web-frontend", "web", api) + .WithHttpsEndpoint(port, port, "angular-http", isProxied: false) + .WithUrl(builder.Required("WebFrontend:Url")) + .WithExternalHttpEndpoints(); + } + + private static IResourceBuilder AddBitwardenNpmApp(this IDistributedApplicationBuilder builder, + string name, string path, IResourceBuilder api, string scriptName = "build:bit:watch") + { + return builder + .AddNpmApp(name, $"{builder.Required("ClientsPath")}/{path}", scriptName) + .WithReference(api) + .WaitFor(api) + .WithExplicitStart(); + } +#endif + +#if ENABLE_NGROK_COMMUNITY_PLUGIN + public static void ConfigureNgrok(this IDistributedApplicationBuilder builder, + (IResourceBuilder, string) tunnelResource) + { + var rawToken = builder.Configuration["NgrokAuthToken"]; + if (string.IsNullOrWhiteSpace(rawToken)) + return; + + var authToken = builder.AddParameter("ngrok-auth-token", rawToken, secret: true); + builder.AddNgrok("billing-webhook-ngrok-endpoint", endpointPort: 59600) + .WithAuthToken(authToken) + .WithTunnelEndpoint(tunnelResource.Item1, tunnelResource.Item2) + .WithExplicitStart(); + } +#endif +} diff --git a/AppHost/Properties/launchSettings.json b/AppHost/Properties/launchSettings.json new file mode 100644 index 000000000000..14f58c388d18 --- /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 000000000000..9946c553aa19 --- /dev/null +++ b/AppHost/appsettings.Development.json @@ -0,0 +1,52 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "SelfHost": false, + "ClientsPath": "../../clients/apps", + "WorkingDirectory": "../dev", + "Services": { + "admin": { + "BasePort": "" + }, + "api": { + "BasePort": "" + }, + "billing": { + "BasePort": "" + }, + "identity": { + "BasePort": "" + }, + "notifications": { + "BasePort": "" + } + }, + "AdditionalProjects": { + }, + "Database": { + "Type": "MsSql", + "Image": "mssql/server:2022-latest", + "Port": 1433, + "Password": "", + "SelfHostPassword": "" + }, + "Scripts": { + "DbMigration": "migrate.ps1", + "AzuriteSetup": "setup_azurite.ps1", + "SecretsSetup": "setup_secrets.ps1" + }, + "NgrokAuthToken": "", + "MailCatcher": { + "Image": "sj26/mailcatcher:latest", + "SmtpPort": 10250, + "WebPort": 1080 + }, + "WebFrontend": { + "Port": 8080, + "Url": "https://bitwarden.test:8080" + } +} diff --git a/AppHost/appsettings.json b/AppHost/appsettings.json new file mode 100644 index 000000000000..31c092aa4501 --- /dev/null +++ b/AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/bitwarden-server.sln b/bitwarden-server.sln index 8a7b157c438e..87d1a0341fc8 100644 --- a/bitwarden-server.sln +++ b/bitwarden-server.sln @@ -1,4 +1,4 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.14.36705.20 d17.14 @@ -149,6 +149,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Server.IntegrationTest", "t EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Setup.Test", "test\Setup.Test\Setup.Test.csproj", "{5A3AB73D-F0E8-4DC6-B072-0D3B394621ED}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AppHost", "AppHost\AppHost.csproj", "{0EEFA4FC-4EEC-4E3F-8ED7-28FD33201701}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -381,7 +383,11 @@ Global {E75E1F10-BC6F-4EB1-BA75-D897C45AEA0D}.Debug|Any CPU.Build.0 = Debug|Any CPU {E75E1F10-BC6F-4EB1-BA75-D897C45AEA0D}.Release|Any CPU.ActiveCfg = Release|Any CPU {E75E1F10-BC6F-4EB1-BA75-D897C45AEA0D}.Release|Any CPU.Build.0 = Release|Any CPU - {5A3AB73D-F0E8-4DC6-B072-0D3B394621ED}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0EEFA4FC-4EEC-4E3F-8ED7-28FD33201701}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0EEFA4FC-4EEC-4E3F-8ED7-28FD33201701}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0EEFA4FC-4EEC-4E3F-8ED7-28FD33201701}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0EEFA4FC-4EEC-4E3F-8ED7-28FD33201701}.Release|Any CPU.Build.0 = Release|Any CPU + {5A3AB73D-F0E8-4DC6-B072-0D3B394621ED}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {5A3AB73D-F0E8-4DC6-B072-0D3B394621ED}.Debug|Any CPU.Build.0 = Debug|Any CPU {5A3AB73D-F0E8-4DC6-B072-0D3B394621ED}.Release|Any CPU.ActiveCfg = Release|Any CPU {5A3AB73D-F0E8-4DC6-B072-0D3B394621ED}.Release|Any CPU.Build.0 = Release|Any CPU