using Aspire.Hosting.Azure; using Aspire.Hosting.JavaScript; 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().SingleOrDefault(); 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(emulator => { emulator .WithBlobPort(10000) .WithQueuePort(10001) .WithTablePort(10002) .WithDataVolume() .WithLifetime(ContainerLifetime.Persistent); }); 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 (imageName, imageTag) = builder.GetImageParts("MailCatcher:Image"); 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); } public static IResourceBuilder ConfigureRedis(this IDistributedApplicationBuilder builder) { var (imageName, imageTag) = builder.GetImageParts("Redis:Image"); if (!int.TryParse(builder.Required("Redis:Port"), out var port)) throw new InvalidOperationException("Invalid value for Redis:Port."); return builder .AddContainer("redis", imageName, imageTag) .WithLifetime(ContainerLifetime.Persistent) .WithEndpoint(port: port, name: "tcp", targetPort: 6379) .WithVolume("redis_data", "/data") .WithArgs("redis-server", "--appendonly", "yes"); } public static IResourceBuilder ConfigureIdp(this IDistributedApplicationBuilder builder) { var (imageName, imageTag) = builder.GetImageParts("Idp:Image"); if (!int.TryParse(builder.Required("Idp:Port"), out var port)) throw new InvalidOperationException("Invalid value for Idp:Port."); return builder .AddContainer("idp", imageName, imageTag) .WithHttpEndpoint(port: port, name: "http", targetPort: 8080) .WithEnvironment("SIMPLESAMLPHP_SP_ENTITY_ID", builder.Required("Idp:SpEntityId")) .WithEnvironment("SIMPLESAMLPHP_SP_ASSERTION_CONSUMER_SERVICE", builder.Required("Idp:SpAcsUrl")) .WithBindMount($"{builder.Required("WorkingDirectory")}/authsources.php", "/var/www/simplesamlphp/config/authsources.php") .WithExplicitStart(); } /// /// Configures and initializes the Bitwarden project services required for the distributed /// application, keyed by service name. /// /// 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 dictionary of configured project resource builders keyed by service name. public static IReadOnlyDictionary> ConfigureServices( this IDistributedApplicationBuilder builder, IResourceBuilder db, IResourceBuilder secretsSetup, IResourceBuilder mail, IResourceBuilder azurite) { var services = new Dictionary> { ["admin"] = builder.AddBitwardenService(db, secretsSetup, mail, "admin"), ["api"] = builder.AddBitwardenService(db, secretsSetup, mail, "api") .WaitFor(azurite), ["billing"] = builder.AddBitwardenService(db, secretsSetup, mail, "billing"), ["events"] = builder.AddBitwardenService(db, secretsSetup, mail, "events") .WaitFor(azurite), ["eventsProcessor"] = builder .AddBitwardenService(db, secretsSetup, mail, "eventsProcessor") .WaitFor(azurite), ["icons"] = builder.AddBitwardenService(db, secretsSetup, mail, "icons"), ["identity"] = builder.AddBitwardenService(db, secretsSetup, mail, "identity"), ["notifications"] = builder .AddBitwardenService(db, secretsSetup, mail, "notifications") .WaitFor(azurite), ["scim"] = builder.AddBitwardenService(db, secretsSetup, mail, "scim"), ["sso"] = builder.AddBitwardenService(db, secretsSetup, mail, "sso") }; builder.ConfigureAdditionalProjects(services); return services; } /// /// 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" or "sso") 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}"); /// /// Reads an image reference from configuration in name[:tag] form and returns its components, /// defaulting the tag to latest when not specified. /// private static (string Name, string Tag) GetImageParts(this IDistributedApplicationBuilder builder, string key) { var parts = builder.Required(key).Split(':'); return (parts[0], parts.Length > 1 ? parts[1] : "latest"); } /// /// 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; 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, port: port) .WithUrl(builder.Required("WebFrontend:Url")) .WithExternalHttpEndpoints(); } private static IResourceBuilder AddBitwardenNpmApp(this IDistributedApplicationBuilder builder, string name, string path, IResourceBuilder api, int port, string scriptName = "build:bit:watch") { return builder .AddJavaScriptApp(name, $"{builder.Required("ClientsPath")}/{path}", scriptName) .WithHttpsEndpoint(port, port, "angular-http", isProxied: false) .WithNpm(install: false) .WithReference(api) .WaitFor(api) .WithExplicitStart(); } #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 }