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