using System.Security.Cryptography; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using OTSSignsOrchestrator.Core.Data; namespace OTSSignsOrchestrator.Core.Services; /// /// Runs once after a Xibo CMS stack is deployed to complete post-install setup: /// /// Waits for the Xibo web service to become available. /// Creates the OTS admin user with a random password. /// Registers a dedicated client_credentials OAuth2 application for OTS. /// Activates the otssigns theme. /// Stores all generated credentials in Bitwarden Secrets Manager. /// /// /// Invoked as a background fire-and-forget task from . /// Progress is tracked in the operation log; failures are logged but do not roll back the deployment. /// public class PostInstanceInitService { private readonly IServiceProvider _services; private readonly ILogger _logger; // How long to wait for Xibo to become healthy before aborting post-init. private static readonly TimeSpan XiboReadinessTimeout = TimeSpan.FromMinutes(15); public PostInstanceInitService( IServiceProvider services, ILogger logger) { _services = services; _logger = logger; } // ───────────────────────────────────────────────────────────────────────── // Entry point // ───────────────────────────────────────────────────────────────────────── /// /// Executes the post-instance initialisation sequence. /// Intended to be called as a background task after stack deployment succeeds. /// public async Task RunAsync(string abbrev, string instanceUrl, CancellationToken ct = default) { _logger.LogInformation("[PostInit] Starting post-instance init for {Abbrev} ({Url})", abbrev, instanceUrl); try { using var scope = _services.CreateScope(); var xibo = scope.ServiceProvider.GetRequiredService(); var bws = scope.ServiceProvider.GetRequiredService(); var settings = scope.ServiceProvider.GetRequiredService(); var db = scope.ServiceProvider.GetRequiredService(); // ── Validate Bitwarden is configured ──────────────────────────── if (!await bws.IsConfiguredAsync()) { _logger.LogWarning( "[PostInit] Bitwarden is not configured — credentials will NOT be stored in Bitwarden. " + "Configure Settings → Bitwarden to enable secret storage."); } // ── Read bootstrap credentials ─────────────────────────────────── var bootstrapClientId = await settings.GetAsync(SettingsService.XiboBootstrapClientId); var bootstrapClientSecret = await settings.GetAsync(SettingsService.XiboBootstrapClientSecret); if (string.IsNullOrWhiteSpace(bootstrapClientId) || string.IsNullOrWhiteSpace(bootstrapClientSecret)) throw new InvalidOperationException( "Xibo bootstrap OAuth2 credentials are not configured. " + "Set Settings → Xibo Bootstrap Client ID / Secret to enable post-instance setup."); // ── 1. Wait for Xibo readiness ──────────────────────────────────── _logger.LogInformation("[PostInit] Waiting for Xibo to become ready at {Url}...", instanceUrl); var ready = await xibo.WaitForReadyAsync(instanceUrl, XiboReadinessTimeout, ct); if (!ready) throw new TimeoutException( $"Xibo instance at {instanceUrl} did not become ready within {XiboReadinessTimeout.TotalMinutes} minutes."); // ── 2. Generate credentials ─────────────────────────────────────── var adminUsername = $"ots-admin-{abbrev}"; var adminPassword = GeneratePassword(24); var adminEmail = $"ots-admin-{abbrev}@ots-signs.com"; // ── 3. Create OTS admin user ────────────────────────────────────── _logger.LogInformation("[PostInit] Creating OTS admin user '{Username}'", adminUsername); int userId = await xibo.CreateAdminUserAsync( instanceUrl, bootstrapClientId, bootstrapClientSecret, adminUsername, adminPassword, adminEmail); // ── 4. Register dedicated OAuth2 application ────────────────────── _logger.LogInformation("[PostInit] Registering OTS OAuth2 application"); var (oauthClientId, oauthClientSecret) = await xibo.RegisterOAuthClientAsync( instanceUrl, bootstrapClientId, bootstrapClientSecret, appName: $"OTS Signs — {abbrev.ToUpperInvariant()}"); // ── 5. Set theme ────────────────────────────────────────────────── _logger.LogInformation("[PostInit] Setting Xibo theme to 'otssigns'"); await xibo.SetThemeAsync(instanceUrl, bootstrapClientId, bootstrapClientSecret, "otssigns"); // ── 6. Store credentials in Bitwarden ───────────────────────────── if (await bws.IsConfiguredAsync()) { _logger.LogInformation("[PostInit] Storing credentials in Bitwarden"); var adminSecretId = await bws.CreateSecretAsync( key: $"{abbrev}/xibo-admin-password", value: adminPassword, note: $"Xibo CMS admin password for instance {abbrev}. Username: {adminUsername}"); var oauthSecretId = await bws.CreateSecretAsync( key: $"{abbrev}/xibo-oauth-secret", value: oauthClientSecret, note: $"Xibo CMS OAuth2 client secret for OTS application. ClientId: {oauthClientId}"); // Persist Bitwarden secret IDs + OAuth client ID in AppSettings for later retrieval await settings.SetAsync(SettingsService.InstanceAdminPasswordSecretId(abbrev), adminSecretId, SettingsService.CatInstance, isSensitive: false); await settings.SetAsync(SettingsService.InstanceOAuthSecretId(abbrev), oauthSecretId, SettingsService.CatInstance, isSensitive: false); await settings.SetAsync(SettingsService.InstanceOAuthClientId(abbrev), oauthClientId, SettingsService.CatInstance, isSensitive: false); } else { // No Bitwarden — fall back to encrypted AppSettings (less ideal for secrets) _logger.LogWarning( "[PostInit] Bitwarden not configured. Storing admin password locally (encrypted). " + "OAuth client secret is discarded — re-register from the instance details modal."); await settings.SetAsync( $"Instance.{abbrev}.AdminPassword", adminPassword, SettingsService.CatInstance, isSensitive: true); await settings.SetAsync(SettingsService.InstanceOAuthClientId(abbrev), oauthClientId, SettingsService.CatInstance, isSensitive: false); } await db.SaveChangesAsync(ct); _logger.LogInformation( "[PostInit] Post-instance init complete for {Abbrev}. Admin user: {Username}, OAuth ClientId: {ClientId}", abbrev, adminUsername, oauthClientId); } catch (Exception ex) { _logger.LogError(ex, "[PostInit] Post-instance init failed for {Abbrev}: {Message}", abbrev, ex.Message); // Do NOT rethrow — the stack is already deployed; we don't want to break the deployment result. } } // ───────────────────────────────────────────────────────────────────────── // Credential rotation (called from the UI) // ───────────────────────────────────────────────────────────────────────── /// /// Generates a new OTS admin password, updates it in Xibo, and rotates the Bitwarden secret. /// public async Task RotateAdminPasswordAsync(string abbrev, string instanceUrl) { _logger.LogInformation("[PostInit] Rotating admin password for {Abbrev}", abbrev); using var scope = _services.CreateScope(); var xibo = scope.ServiceProvider.GetRequiredService(); var bws = scope.ServiceProvider.GetRequiredService(); var settings = scope.ServiceProvider.GetRequiredService(); var db = scope.ServiceProvider.GetRequiredService(); // Get bootstrap credentials to authenticate against Xibo API var bootstrapClientId = await settings.GetAsync(SettingsService.XiboBootstrapClientId) ?? throw new InvalidOperationException("Xibo bootstrap client not configured."); var bootstrapClientSecret = await settings.GetAsync(SettingsService.XiboBootstrapClientSecret) ?? throw new InvalidOperationException("Xibo bootstrap client secret not configured."); // Xibo user ID: we store it as the numeric userId in the username convention // We need to look it up from the admin username. For now derive from OTS convention. var adminUsername = $"ots-admin-{abbrev}"; var newPassword = GeneratePassword(24); // We need to look up the userId — use the Xibo API (list users, find by userName) var userId = await GetXiboUserIdAsync(xibo, instanceUrl, bootstrapClientId, bootstrapClientSecret, adminUsername); await xibo.RotateUserPasswordAsync(instanceUrl, bootstrapClientId, bootstrapClientSecret, userId, newPassword); // Update Bitwarden secret if available if (await bws.IsConfiguredAsync()) { var secretId = await settings.GetAsync(SettingsService.InstanceAdminPasswordSecretId(abbrev)); if (!string.IsNullOrWhiteSpace(secretId)) { await bws.UpdateSecretAsync(secretId, key: $"{abbrev}/xibo-admin-password", value: newPassword, note: $"Rotated on {DateTime.UtcNow:yyyy-MM-dd HH:mm} UTC. Username: {adminUsername}"); } else { // Secret doesn't exist yet in Bitwarden — create it now var newSecretId = await bws.CreateSecretAsync( $"{abbrev}/xibo-admin-password", newPassword, $"Rotated on {DateTime.UtcNow:yyyy-MM-dd HH:mm} UTC. Username: {adminUsername}"); await settings.SetAsync(SettingsService.InstanceAdminPasswordSecretId(abbrev), newSecretId, SettingsService.CatInstance, isSensitive: false); } } else { // Fallback: encrypted AppSettings await settings.SetAsync($"Instance.{abbrev}.AdminPassword", newPassword, SettingsService.CatInstance, isSensitive: true); } await db.SaveChangesAsync(); _logger.LogInformation("[PostInit] Admin password rotated for {Abbrev}", abbrev); return newPassword; } /// /// Returns the stored admin password for an instance (from Bitwarden or local AppSettings). /// public async Task GetCredentialsAsync(string abbrev) { using var scope = _services.CreateScope(); var bws = scope.ServiceProvider.GetRequiredService(); var settings = scope.ServiceProvider.GetRequiredService(); var adminUsername = $"ots-admin-{abbrev}"; var oauthClientId = await settings.GetAsync(SettingsService.InstanceOAuthClientId(abbrev)); string? adminPassword = null; string? oauthClientSecret = null; if (await bws.IsConfiguredAsync()) { var adminSecretId = await settings.GetAsync(SettingsService.InstanceAdminPasswordSecretId(abbrev)); if (!string.IsNullOrWhiteSpace(adminSecretId)) { try { var secret = await bws.GetSecretAsync(adminSecretId); adminPassword = secret.Value; } catch (Exception ex) { _logger.LogWarning(ex, "Could not retrieve admin password from Bitwarden for {Abbrev}", abbrev); } } var oauthSecretId = await settings.GetAsync(SettingsService.InstanceOAuthSecretId(abbrev)); if (!string.IsNullOrWhiteSpace(oauthSecretId)) { try { var secret = await bws.GetSecretAsync(oauthSecretId); oauthClientSecret = secret.Value; } catch (Exception ex) { _logger.LogWarning(ex, "Could not retrieve OAuth secret from Bitwarden for {Abbrev}", abbrev); } } } else { adminPassword = await settings.GetAsync($"Instance.{abbrev}.AdminPassword"); } return new InstanceCredentials { AdminUsername = adminUsername, AdminPassword = adminPassword, OAuthClientId = oauthClientId, OAuthClientSecret = oauthClientSecret, }; } // ───────────────────────────────────────────────────────────────────────── // Helpers // ───────────────────────────────────────────────────────────────────────── private static string GeneratePassword(int length) { const string chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*"; return RandomNumberGenerator.GetString(chars, length); } private static async Task GetXiboUserIdAsync( XiboApiService xibo, string instanceUrl, string clientId, string clientSecret, string targetUsername) { // This is a lightweight HTTP GET to fetch the user list and find by name. // We use HttpClient directly since XiboApiService doesn't expose this as a standalone call. using var http = new System.Net.Http.HttpClient(); var baseUrl = instanceUrl.TrimEnd('/'); // Get token first via the TestConnectionAsync pattern var form = new FormUrlEncodedContent(new[] { new KeyValuePair("grant_type", "client_credentials"), new KeyValuePair("client_id", clientId), new KeyValuePair("client_secret", clientSecret), }); var tokenResp = await http.PostAsync($"{baseUrl}/api/authorize/access_token", form); tokenResp.EnsureSuccessStatusCode(); using var tokenDoc = await System.Text.Json.JsonDocument.ParseAsync( await tokenResp.Content.ReadAsStreamAsync()); var token = tokenDoc.RootElement.GetProperty("access_token").GetString()!; http.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); var usersResp = await http.GetAsync($"{baseUrl}/api/user?userName={Uri.EscapeDataString(targetUsername)}"); usersResp.EnsureSuccessStatusCode(); using var usersDoc = await System.Text.Json.JsonDocument.ParseAsync( await usersResp.Content.ReadAsStreamAsync()); foreach (var user in usersDoc.RootElement.EnumerateArray()) { var name = user.GetProperty("userName").GetString(); if (string.Equals(name, targetUsername, StringComparison.OrdinalIgnoreCase)) return user.GetProperty("userId").GetInt32(); } throw new InvalidOperationException( $"Xibo user '{targetUsername}' not found. Post-instance init may not have completed yet."); } } /// Snapshot of provisioned credentials for a CMS instance. public class InstanceCredentials { public string AdminUsername { get; set; } = string.Empty; public string? AdminPassword { get; set; } public string? OAuthClientId { get; set; } public string? OAuthClientSecret { get; set; } public bool HasAdminPassword => !string.IsNullOrWhiteSpace(AdminPassword); public bool HasOAuthCredentials => !string.IsNullOrWhiteSpace(OAuthClientId) && !string.IsNullOrWhiteSpace(OAuthClientSecret); }