diff --git a/.template-cache/2dc03e2b2b45fef3 b/.template-cache/2dc03e2b2b45fef3 index 292fbb4..af33306 160000 --- a/.template-cache/2dc03e2b2b45fef3 +++ b/.template-cache/2dc03e2b2b45fef3 @@ -1 +1 @@ -Subproject commit 292fbb4bfe873e760b8dd27a70acc072cbe98dc9 +Subproject commit af33306def1df30ddcdc28b7c11c494362fc39f3 diff --git a/OTSSignsOrchestrator.Core/Configuration/AppConstants.cs b/OTSSignsOrchestrator.Core/Configuration/AppConstants.cs index 497a94e..6fa8268 100644 --- a/OTSSignsOrchestrator.Core/Configuration/AppConstants.cs +++ b/OTSSignsOrchestrator.Core/Configuration/AppConstants.cs @@ -33,7 +33,6 @@ public static class AppConstants public static string[] AllCustomerMysqlSecretNames(string abbrev) => new[] { - CustomerMysqlPasswordSecretName(abbrev), CustomerMysqlUserSecretName(abbrev), }; diff --git a/OTSSignsOrchestrator.Core/Services/ComposeRenderService.cs b/OTSSignsOrchestrator.Core/Services/ComposeRenderService.cs index 48b2590..edcdf90 100644 --- a/OTSSignsOrchestrator.Core/Services/ComposeRenderService.cs +++ b/OTSSignsOrchestrator.Core/Services/ComposeRenderService.cs @@ -47,6 +47,7 @@ public class ComposeRenderService .Replace("{{MYSQL_PORT}}", ctx.MySqlPort) .Replace("{{MYSQL_DATABASE}}", ctx.MySqlDatabase) .Replace("{{MYSQL_USER}}", ctx.MySqlUser) + .Replace("{{MYSQL_PASSWORD}}", ctx.MySqlPassword) .Replace("{{SMTP_SERVER}}", ctx.SmtpServer) .Replace("{{SMTP_USERNAME}}", ctx.SmtpUsername) .Replace("{{SMTP_PASSWORD}}", ctx.SmtpPassword) @@ -168,7 +169,7 @@ public class ComposeRenderService MYSQL_PORT: "{{MYSQL_PORT}}" MYSQL_DATABASE: {{MYSQL_DATABASE}} MYSQL_USER: {{MYSQL_USER}} - MYSQL_PASSWORD_FILE: /run/secrets/{{ABBREV}}-cms-db-password + MYSQL_PASSWORD: {{MYSQL_PASSWORD}} CMS_SERVER_NAME: {{CMS_SERVER_NAME}} CMS_SMTP_SERVER: {{SMTP_SERVER}} CMS_SMTP_USERNAME: {{SMTP_USERNAME}} @@ -182,7 +183,6 @@ public class ComposeRenderService CMS_PHP_UPLOAD_MAX_FILESIZE: {{PHP_UPLOAD_MAX_FILESIZE}} CMS_PHP_MAX_EXECUTION_TIME: "{{PHP_MAX_EXECUTION_TIME}}" secrets: - - {{ABBREV}}-cms-db-password - {{ABBREV}}-cms-db-user - global_mysql_host - global_mysql_port @@ -280,8 +280,6 @@ public class ComposeRenderService o: "{{NFS_OPTS}}" secrets: - {{ABBREV}}-cms-db-password: - external: true {{ABBREV}}-cms-db-user: external: true global_mysql_host: @@ -314,6 +312,7 @@ public class RenderContext public string MySqlPort { get; set; } = "3306"; public string MySqlDatabase { get; set; } = "cms"; public string MySqlUser { get; set; } = "cms"; + public string MySqlPassword { get; set; } = string.Empty; // SMTP public string SmtpServer { get; set; } = string.Empty; diff --git a/OTSSignsOrchestrator.Core/Services/ComposeValidationService.cs b/OTSSignsOrchestrator.Core/Services/ComposeValidationService.cs index f7ba170..5581e43 100644 --- a/OTSSignsOrchestrator.Core/Services/ComposeValidationService.cs +++ b/OTSSignsOrchestrator.Core/Services/ComposeValidationService.cs @@ -111,7 +111,6 @@ public class ComposeValidationService { var requiredSecrets = new[] { - AppConstants.CustomerMysqlPasswordSecretName(customerAbbrev), AppConstants.CustomerMysqlUserSecretName(customerAbbrev), AppConstants.GlobalMysqlHostSecretName, AppConstants.GlobalMysqlPortSecretName, diff --git a/OTSSignsOrchestrator.Core/Services/InstanceService.cs b/OTSSignsOrchestrator.Core/Services/InstanceService.cs index 8870d98..e8b436f 100644 --- a/OTSSignsOrchestrator.Core/Services/InstanceService.cs +++ b/OTSSignsOrchestrator.Core/Services/InstanceService.cs @@ -101,18 +101,12 @@ public class InstanceService await _docker.RemoveStackAsync(stackName); await Task.Delay(2000); - // ── 2. Generate MySQL credentials → Docker Swarm secrets ──────── + // ── 2. Generate MySQL credentials ────────────────────────────── var mysqlPassword = GenerateRandomPassword(32); var mySqlUserName = (await _settings.GetAsync(SettingsService.DefaultMySqlUserTemplate, "{abbrev}_cms_user")).Replace("{abbrev}", abbrev); - var pwdSecretName = CustomerMysqlPasswordSecretName(abbrev); var userSecretName = CustomerMysqlUserSecretName(abbrev); - var (pwdOk, _) = await _secrets.EnsureSecretAsync(pwdSecretName, mysqlPassword, rotate: true); - if (!pwdOk) - throw new InvalidOperationException($"Failed to create/rotate Docker secret '{pwdSecretName}'. Is a stale stack still referencing it?"); - _logger.LogInformation("Docker secret created/rotated: {SecretName}", pwdSecretName); - var (userOk, _) = await _secrets.EnsureSecretAsync(userSecretName, mySqlUserName, rotate: true); if (!userOk) throw new InvalidOperationException($"Failed to create/rotate Docker secret '{userSecretName}'."); @@ -132,7 +126,12 @@ public class InstanceService throw new InvalidOperationException($"MySQL database/user setup failed: {mysqlMsg}"); _logger.LogInformation("MySQL setup complete: {Message}", mysqlMsg); - mysqlPassword = string.Empty; + // ── 2c. Persist password (encrypted) for future redeploys ──────── + await _settings.SetAsync( + SettingsService.InstanceMySqlPassword(abbrev), mysqlPassword, + SettingsService.CatInstance, isSensitive: true); + await _db.SaveChangesAsync(); + _logger.LogInformation("MySQL password stored in settings for instance {Abbrev}", abbrev); // ── 3. Read settings ──────────────────────────────────────────── var mySqlHost = mySqlHostValue; @@ -185,6 +184,7 @@ public class InstanceService MySqlPort = mySqlPort, MySqlDatabase = mySqlDbName, MySqlUser = mySqlUser, + MySqlPassword = mysqlPassword, SmtpServer = smtpServer, SmtpUsername = smtpUsername, SmtpPassword = smtpPassword, @@ -244,6 +244,8 @@ public class InstanceService if (!deployResult.Success) throw new InvalidOperationException($"Stack deployment failed: {deployResult.ErrorMessage}"); + mysqlPassword = string.Empty; + sw.Stop(); opLog.Status = OperationStatus.Success; opLog.Message = $"Instance deployed: {stackName}"; @@ -296,6 +298,14 @@ public class InstanceService var mySqlDbName = (await _settings.GetAsync(SettingsService.DefaultMySqlDbTemplate, "{abbrev}_cms_db")).Replace("{abbrev}", abbrev); var mySqlUser = (await _settings.GetAsync(SettingsService.DefaultMySqlUserTemplate, "{abbrev}_cms_user")).Replace("{abbrev}", abbrev); + // Retrieve the stored MySQL password (encrypted in settings since creation) + var mySqlPassword = await _settings.GetAsync(SettingsService.InstanceMySqlPassword(abbrev)); + if (string.IsNullOrEmpty(mySqlPassword)) + throw new InvalidOperationException( + $"No stored MySQL password found for instance '{abbrev}'. " + + "The instance may have been created before password persistence was added. " + + "Use 'Rotate Password' to generate and store a new one."); + // Ensure MySQL Docker secrets exist (idempotent) var userSecretName = CustomerMysqlUserSecretName(abbrev); await _secrets.EnsureSecretAsync(userSecretName, mySqlUser); @@ -357,6 +367,7 @@ public class InstanceService MySqlPort = mySqlPort, MySqlDatabase = mySqlDbName, MySqlUser = mySqlUser, + MySqlPassword = mySqlPassword, SmtpServer = smtpServer, SmtpUsername = smtpUsername, SmtpPassword = smtpPassword, @@ -470,6 +481,12 @@ public class InstanceService foreach (var secretName in AllCustomerMysqlSecretNames(abbrev)) await _secrets.DeleteSecretAsync(secretName); + + // Remove the stored password from local settings + await _settings.SetAsync( + SettingsService.InstanceMySqlPassword(abbrev), null, + SettingsService.CatInstance, isSensitive: false); + await _db.SaveChangesAsync(); } sw.Stop(); @@ -506,23 +523,11 @@ public class InstanceService public async Task<(bool Success, string Message)> RotateMySqlPasswordAsync(string stackName, string? userId = null) { var abbrev = ExtractAbbrev(stackName); - var secretName = CustomerMysqlPasswordSecretName(abbrev); - var tempSecret = $"{secretName}-rot"; - var webService = $"{stackName}_{abbrev}-web"; var newPassword = GenerateRandomPassword(32); _logger.LogInformation("Rotating MySQL password for instance {StackName}", stackName); - // ── Step 1: Create temp secret (new value, different name) ──────────── - // We cannot delete the live secret while the service is using it, so we - // stage the new value under a temporary name first. - var (tempCreated, _) = await _secrets.EnsureSecretAsync(tempSecret, newPassword, rotate: false); - if (!tempCreated) - return (false, $"Failed to create temporary rotation secret '{tempSecret}'."); - - // ── Step 2: Update MySQL password via SSH ───────────────────────────── - // Connect through the Docker SSH host so the desktop app doesn't need - // direct TCP access to the MySQL server. + // ── Step 1: Update MySQL password via SSH tunnel ───────────────────── var mySqlHost = await _settings.GetAsync(SettingsService.MySqlHost, "localhost"); var mySqlPort = await _settings.GetAsync(SettingsService.MySqlPort, "3306"); var mySqlAdminUser = await _settings.GetAsync(SettingsService.MySqlAdminUser, "root"); @@ -535,51 +540,30 @@ public class InstanceService mySqlHost, port, mySqlAdminUser, mySqlAdminPassword, mySqlUser, newPassword); if (!mysqlOk) - { - // No service swap has happened yet — clean up temp and abort cleanly. - await _secrets.DeleteSecretAsync(tempSecret); - return (false, $"MySQL update failed (no service disruption): {mysqlErr}"); - } + return (false, $"MySQL ALTER USER failed (no service disruption): {mysqlErr}"); _logger.LogInformation("MySQL password updated for user {User}", mySqlUser); - // ── Step 3: Swap service from old secret → temp (same /run/secrets/ path) - // Triggers rolling restart; containers pick up the new password. - if (!await _docker.ServiceSwapSecretAsync(webService, secretName, tempSecret, targetAlias: secretName)) - { - // Rollback MySQL — try to restore old password. We don't know it, so - // log a warning; the temp secret can be cleaned up manually. - _logger.LogError( - "Service swap failed. MySQL has new password but service still uses old secret. " + - "Manual intervention may be required for {Service}", webService); - return (false, $"Service swap failed for '{webService}'. MySQL password was updated but secret swap did not complete."); - } + // ── Step 2: Persist new password in settings (encrypted) ───────────── + await _settings.SetAsync( + SettingsService.InstanceMySqlPassword(abbrev), newPassword, + SettingsService.CatInstance, isSensitive: true); + await _db.SaveChangesAsync(); + _logger.LogInformation("New MySQL password stored in settings for instance {Abbrev}", abbrev); - _logger.LogInformation("Service {Service} rolling restart with temp secret {TempSecret}", webService, tempSecret); - - // ── Step 4: Delete the old secret (now unreferenced by any service) ─── - await _secrets.DeleteSecretAsync(secretName); - - // ── Step 5: Recreate permanent secret under the original name ───────── - var (secretCreated, secretId) = await _secrets.EnsureSecretAsync(secretName, newPassword, rotate: false); - if (!secretCreated) - { - _logger.LogError("Failed to recreate permanent secret {SecretName}; temp secret remains active", secretName); - return (false, $"Rotation partially complete: temp secret '{tempSecret}' is active but '{secretName}' could not be recreated."); - } - - // ── Step 6: Swap service back to permanent name; second rolling restart ─ - if (!await _docker.ServiceSwapSecretAsync(webService, tempSecret, secretName)) + // ── Step 3: Force-update the web service to pick up new compose env ── + // Redeploy the stack so the MYSQL_PASSWORD env var gets the new value. + var webService = $"{stackName}_{abbrev}-web"; + if (!await _docker.ForceUpdateServiceAsync(webService)) { _logger.LogWarning( - "Final swap to permanent secret failed for {Service}; temp secret '{TempSecret}' is still active. " + - "Service is functional but secret name normalisation is pending.", webService, tempSecret); - return (true, $"MySQL password rotated for '{stackName}' (temp secret active; manual final swap may be needed)."); + "Force-update of service {Service} failed. MySQL has the new password but " + + "the container is still running with the old value. A manual redeploy may be needed.", + webService); + return (true, $"MySQL password rotated for '{stackName}' but service force-update failed. Redeploy to apply."); } - await _secrets.DeleteSecretAsync(tempSecret); - - _logger.LogInformation("Docker secret fully rotated: {SecretName} (new id={SecretId})", secretName, secretId); + _logger.LogInformation("Service {Service} force-updated with new password", webService); return (true, $"MySQL password rotated successfully for '{stackName}'."); } @@ -750,8 +734,6 @@ public class InstanceService private static string GenerateRandomPassword(int length) { - // Only alphanumeric chars — special characters (! @ # $ % ^ & *) can be - // mangled by shell escaping in the printf→docker-secret-create SSH pipeline. const string chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; return RandomNumberGenerator.GetString(chars, length); } diff --git a/OTSSignsOrchestrator.Core/Services/SettingsService.cs b/OTSSignsOrchestrator.Core/Services/SettingsService.cs index c15248e..a55a8a8 100644 --- a/OTSSignsOrchestrator.Core/Services/SettingsService.cs +++ b/OTSSignsOrchestrator.Core/Services/SettingsService.cs @@ -68,6 +68,14 @@ public class SettingsService public const string DefaultPhpUploadMaxFilesize = "Defaults.PhpUploadMaxFilesize"; public const string DefaultPhpMaxExecutionTime = "Defaults.PhpMaxExecutionTime"; + // Instance-specific (keyed by abbreviation) + /// + /// Builds a per-instance settings key for the MySQL password. + /// Stored encrypted via DataProtection so it can be retrieved on update/redeploy. + /// + public static string InstanceMySqlPassword(string abbrev) => $"Instance.{abbrev}.MySqlPassword"; + public const string CatInstance = "Instance"; + public SettingsService( XiboContext db, IDataProtectionProvider dataProtection, diff --git a/OTSSignsOrchestrator.Desktop/Services/SshDockerSecretsService.cs b/OTSSignsOrchestrator.Desktop/Services/SshDockerSecretsService.cs index ee322f9..cf818a3 100644 --- a/OTSSignsOrchestrator.Desktop/Services/SshDockerSecretsService.cs +++ b/OTSSignsOrchestrator.Desktop/Services/SshDockerSecretsService.cs @@ -1,5 +1,4 @@ using System.Globalization; -using System.Text; using Microsoft.Extensions.Logging; using OTSSignsOrchestrator.Core.Models.Entities; using OTSSignsOrchestrator.Core.Services; diff --git a/template.yml b/template.yml index 452b9bb..a069bda 100644 --- a/template.yml +++ b/template.yml @@ -12,7 +12,7 @@ services: MYSQL_PORT: "{{MYSQL_PORT}}" MYSQL_DATABASE: {{MYSQL_DATABASE}} MYSQL_USER: {{MYSQL_USER}} - MYSQL_PASSWORD_FILE: /run/secrets/{{ABBREV}}-cms-db-password + MYSQL_PASSWORD: {{MYSQL_PASSWORD}} CMS_SERVER_NAME: {{CMS_SERVER_NAME}} CMS_SMTP_SERVER: {{SMTP_SERVER}} CMS_SMTP_USERNAME: {{SMTP_USERNAME}} @@ -26,7 +26,6 @@ services: CMS_PHP_UPLOAD_MAX_FILESIZE: {{PHP_UPLOAD_MAX_FILESIZE}} CMS_PHP_MAX_EXECUTION_TIME: "{{PHP_MAX_EXECUTION_TIME}}" secrets: - - {{ABBREV}}-cms-db-password - {{ABBREV}}-cms-db-user - global_mysql_host - global_mysql_port @@ -124,8 +123,6 @@ volumes: o: "{{NFS_OPTS}}" secrets: - {{ABBREV}}-cms-db-password: - external: true {{ABBREV}}-cms-db-user: external: true global_mysql_host: