using System.Security.Cryptography; using System.Text.RegularExpressions; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using OTSSignsOrchestrator.Core.Configuration; 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. /// Authenticates using the OAuth2 application credentials supplied by the user. /// 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. /// Deletes the default xibo_admin account. /// /// /// The user must first create an OAuth2 application (client_credentials) in the /// Xibo web UI using the default xibo_admin / password account that ships /// with every new Xibo CMS instance. /// /// Invoked from the Create Instance UI after the user supplies the OAuth credentials. /// 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. /// Called from the UI after the user supplies OAuth2 client credentials. /// public async Task RunAsync( string abbrev, string instanceUrl, string clientId, string clientSecret, 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(); // ── 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. Authenticate with user-supplied OAuth2 credentials ───────── _logger.LogInformation("[PostInit] Obtaining access token via client_credentials"); var accessToken = await xibo.LoginAsync(instanceUrl, clientId, clientSecret); // ── 3. Generate credentials ─────────────────────────────────────── var adminUsername = $"ots-admin-{abbrev}"; var adminPassword = GeneratePassword(24); var adminEmail = $"ots-admin-{abbrev}@ots-signs.com"; // ── 4. Create OTS admin group ───────────────────────────────────── var adminGroupName = $"ots-admins-{abbrev}"; _logger.LogInformation("[PostInit] Creating OTS admin group '{GroupName}'", adminGroupName); var adminGroupId = await xibo.CreateUserGroupAsync(instanceUrl, accessToken, adminGroupName); // ── 5. Create OTS admin user ────────────────────────────────────── _logger.LogInformation("[PostInit] Creating OTS admin user '{Username}'", adminUsername); int userId = await xibo.CreateAdminUserAsync( instanceUrl, accessToken, adminUsername, adminPassword, adminEmail, adminGroupId); // ── 5a. Assign admin user to OTS admin group ────────────────────── _logger.LogInformation("[PostInit] Assigning '{Username}' to group '{GroupName}'", adminUsername, adminGroupName); await xibo.AssignUserToGroupAsync(instanceUrl, accessToken, adminGroupId, userId); // ── 6. Register dedicated OAuth2 application for OTS ────────────── _logger.LogInformation("[PostInit] Registering OTS OAuth2 application"); var (otsClientId, otsClientSecret) = await xibo.RegisterOAuthClientAsync( instanceUrl, accessToken, $"OTS Signs — {abbrev.ToUpperInvariant()}"); // ── 6. Set theme ────────────────────────────────────────────────── _logger.LogInformation("[PostInit] Setting Xibo theme to 'otssigns'"); await xibo.SetThemeAsync(instanceUrl, accessToken, "otssigns"); // ── 6a. Deploy SAML configuration ───────────────────────────────── await DeploySamlConfigurationAsync(abbrev, instanceUrl, settings, ct); // ── 7. Store credentials in Bitwarden ───────────────────────────── _logger.LogInformation("[PostInit] Storing credentials in Bitwarden"); var adminSecretId = await bws.CreateInstanceSecretAsync( key: $"{abbrev}/xibo-admin-password", value: adminPassword, note: $"Xibo CMS admin password for instance {abbrev}. Username: {adminUsername}"); var oauthSecretId = await bws.CreateInstanceSecretAsync( key: $"{abbrev}/xibo-oauth-secret", value: otsClientSecret, note: $"Xibo CMS OAuth2 client secret for OTS application. ClientId: {otsClientId}"); // Persist Bitwarden secret IDs + OAuth client ID as config settings 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), otsClientId, SettingsService.CatInstance, isSensitive: false); // ── 8. Remove the default xibo_admin account ────────────────────── _logger.LogInformation("[PostInit] Removing default xibo_admin user"); var xiboAdminId = await xibo.GetUserIdByNameAsync(instanceUrl, accessToken, "xibo_admin"); await xibo.DeleteUserAsync(instanceUrl, accessToken, xiboAdminId); _logger.LogInformation("[PostInit] xibo_admin user removed (userId={UserId})", xiboAdminId); _logger.LogInformation( "[PostInit] Post-instance init complete for {Abbrev}. Admin user: {Username}, OAuth ClientId: {ClientId}", abbrev, adminUsername, otsClientId); } catch (Exception ex) { _logger.LogError(ex, "[PostInit] Post-instance init failed for {Abbrev}: {Message}", abbrev, ex.Message); throw; // Propagate to calling UI so the user sees the error } } // ───────────────────────────────────────────────────────────────────────── // Initialise using caller-supplied OAuth credentials (no new app registration) // ───────────────────────────────────────────────────────────────────────── /// /// Initialises a Xibo CMS instance using OAuth2 credentials supplied by the user. /// Unlike , this method does NOT register a new OAuth application; /// instead it stores the caller-supplied credentials for future API operations. /// Steps: wait → authenticate → create OTS admin → set theme → remove xibo_admin → store credentials. /// public async Task InitializeWithOAuthAsync( string abbrev, string instanceUrl, string clientId, string clientSecret, CancellationToken ct = default) { _logger.LogInformation("[PostInit] Starting initialisation 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(); // ── 1. Wait for Xibo readiness ──────────────────────────────────── _logger.LogInformation("[PostInit] Waiting for Xibo at {Url}...", instanceUrl); var ready = await xibo.WaitForReadyAsync(instanceUrl, XiboReadinessTimeout, ct); if (!ready) throw new TimeoutException( $"Xibo at {instanceUrl} did not become ready within {XiboReadinessTimeout.TotalMinutes} minutes."); // ── 2. Authenticate with caller-supplied OAuth2 credentials ─────── _logger.LogInformation("[PostInit] Obtaining access token"); var accessToken = await xibo.LoginAsync(instanceUrl, clientId, clientSecret); // ── 3. Generate OTS admin credentials ───────────────────────────── var adminUsername = $"ots-admin-{abbrev}"; var adminPassword = GeneratePassword(24); var adminEmail = $"ots-admin-{abbrev}@ots-signs.com"; // ── 4. Rename built-in xibo_admin to OTS admin ─────────────────── _logger.LogInformation("[PostInit] Looking up xibo_admin user"); var xiboAdminId = await xibo.GetUserIdByNameAsync(instanceUrl, accessToken, "xibo_admin"); _logger.LogInformation("[PostInit] Updating xibo_admin (id={Id}) → '{Username}'", xiboAdminId, adminUsername); await xibo.UpdateUserAsync(instanceUrl, accessToken, xiboAdminId, adminUsername, adminPassword, adminEmail); // ── 5. Set theme ────────────────────────────────────────────────── _logger.LogInformation("[PostInit] Setting theme to 'otssigns'"); await xibo.SetThemeAsync(instanceUrl, accessToken, "otssigns"); // ── 5a. Deploy SAML configuration ───────────────────────────────── await DeploySamlConfigurationAsync(abbrev, instanceUrl, settings, ct); // ── 6. Store admin password in Bitwarden ────────────────────────── _logger.LogInformation("[PostInit] Storing credentials in Bitwarden"); var adminSecretId = await bws.CreateInstanceSecretAsync( key: $"{abbrev}/xibo-admin-password", value: adminPassword, note: $"Xibo CMS admin password for instance {abbrev}. Username: {adminUsername}"); await settings.SetAsync(SettingsService.InstanceAdminPasswordSecretId(abbrev), adminSecretId, SettingsService.CatInstance, isSensitive: false); // ── 7. Store caller-supplied OAuth credentials in Bitwarden ─────── var oauthSecretId = await bws.CreateInstanceSecretAsync( key: $"{abbrev}/xibo-oauth-secret", value: clientSecret, note: $"Xibo CMS OAuth2 client secret for instance {abbrev}. ClientId: {clientId}"); await settings.SetAsync(SettingsService.InstanceOAuthSecretId(abbrev), oauthSecretId, SettingsService.CatInstance, isSensitive: false); await settings.SetAsync(SettingsService.InstanceOAuthClientId(abbrev), clientId, SettingsService.CatInstance, isSensitive: false); _logger.LogInformation("[PostInit] xibo_admin removed"); _logger.LogInformation("[PostInit] Initialisation complete for {Abbrev}", abbrev); } catch (Exception ex) { _logger.LogError(ex, "[PostInit] Initialisation failed for {Abbrev}: {Message}", abbrev, ex.Message); throw; } } // ───────────────────────────────────────────────────────────────────────── // 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 adminUsername = $"ots-admin-{abbrev}"; var newPassword = GeneratePassword(24); // Log in using the stored OTS OAuth2 client credentials var oauthClientId = await settings.GetAsync(SettingsService.InstanceOAuthClientId(abbrev)); var oauthSecretId = await settings.GetAsync(SettingsService.InstanceOAuthSecretId(abbrev)); if (string.IsNullOrWhiteSpace(oauthClientId) || string.IsNullOrWhiteSpace(oauthSecretId)) throw new InvalidOperationException( $"No OAuth credentials found for instance '{abbrev}'. Was post-init completed?"); var oauthSecret = await bws.GetSecretAsync(oauthSecretId); var accessToken = await xibo.LoginAsync(instanceUrl, oauthClientId, oauthSecret.Value); // Look up the OTS admin user ID var userId = await xibo.GetUserIdByNameAsync(instanceUrl, accessToken, adminUsername); await xibo.RotateUserPasswordAsync(instanceUrl, accessToken, userId, newPassword); // Update Bitwarden secret var secretId = await settings.GetAsync(SettingsService.InstanceAdminPasswordSecretId(abbrev)); if (!string.IsNullOrWhiteSpace(secretId)) { await bws.UpdateInstanceSecretAsync(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.CreateInstanceSecretAsync( $"{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); } _logger.LogInformation("[PostInit] Admin password rotated for {Abbrev}", abbrev); return newPassword; } /// /// Returns the stored admin password for an instance from Bitwarden. /// 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; 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); } } return new InstanceCredentials { AdminUsername = adminUsername, AdminPassword = adminPassword, OAuthClientId = oauthClientId, OAuthClientSecret = oauthClientSecret, }; } // ───────────────────────────────────────────────────────────────────────── // SAML configuration deployment // ───────────────────────────────────────────────────────────────────────── /// /// Provisions a SAML application in Authentik, renders the settings-custom.php template, /// and writes the rendered file to the instance's NFS-backed cms-custom volume. /// Errors are logged but do not fail the overall post-init process. /// private async Task DeploySamlConfigurationAsync( string abbrev, string instanceUrl, SettingsService settings, CancellationToken ct) { try { _logger.LogInformation("[PostInit] Deploying SAML settings-custom.php for {Abbrev}", abbrev); using var scope = _services.CreateScope(); var authentik = scope.ServiceProvider.GetRequiredService(); var git = scope.ServiceProvider.GetRequiredService(); var docker = scope.ServiceProvider.GetRequiredService(); // ── 1. Fetch template from git repo ─────────────────────────────── var repoUrl = await settings.GetAsync(SettingsService.GitRepoUrl); var repoPat = await settings.GetAsync(SettingsService.GitRepoPat); if (string.IsNullOrWhiteSpace(repoUrl)) throw new InvalidOperationException("Git repository URL is not configured."); var templateConfig = await git.FetchAsync(repoUrl, repoPat); var templatePath = Path.Combine(templateConfig.CacheDir, "settings-custom.php.template"); if (!File.Exists(templatePath)) { _logger.LogWarning( "[PostInit] settings-custom.php.template not found in git repo — skipping SAML deployment"); return; } var templateContent = await File.ReadAllTextAsync(templatePath, ct); // ── 2. Provision Authentik SAML application ─────────────────────── var samlBaseUrl = instanceUrl.TrimEnd('/') + "/saml"; var samlConfig = await authentik.ProvisionSamlAsync(abbrev, instanceUrl, ct); // ── 3. Render template ──────────────────────────────────────────── var rendered = templateContent .Replace("{{SAML_BASE_URL}}", samlBaseUrl) .Replace("{{SAML_SP_ENTITY_ID}}", $"{samlBaseUrl}/metadata") .Replace("{{AUTHENTIK_IDP_ENTITY_ID}}", samlConfig.IdpEntityId) .Replace("{{AUTHENTIK_SSO_URL}}", samlConfig.SsoUrlRedirect) .Replace("{{AUTHENTIK_SLO_URL}}", samlConfig.SloUrlRedirect) .Replace("{{AUTHENTIK_IDP_X509_CERT}}", samlConfig.IdpX509Cert); // ── 4. Write rendered file to NFS volume ────────────────────────── var nfsServer = await settings.GetAsync(SettingsService.NfsServer); var nfsExport = await settings.GetAsync(SettingsService.NfsExport); var nfsExportFolder = await settings.GetAsync(SettingsService.NfsExportFolder); if (string.IsNullOrWhiteSpace(nfsServer) || string.IsNullOrWhiteSpace(nfsExport)) throw new InvalidOperationException("NFS settings are not configured — cannot write SAML config to volume."); // Path within the NFS export: {abbrev}/cms-custom/settings-custom.php var nfsRelativePath = $"{abbrev}/cms-custom/settings-custom.php"; var (success, error) = await docker.WriteFileToNfsAsync( nfsServer, nfsExport, nfsRelativePath, rendered, nfsExportFolder); if (!success) throw new InvalidOperationException($"Failed to write settings-custom.php to NFS: {error}"); _logger.LogInformation( "[PostInit] SAML configuration deployed for {Abbrev} (Authentik provider={ProviderId})", abbrev, samlConfig.ProviderId); } catch (Exception ex) { _logger.LogError(ex, "[PostInit] SAML deployment failed for {Abbrev}: {Message}. " + "Instance will continue without SAML — configure manually if needed.", abbrev, ex.Message); // Don't rethrow — SAML failure should not block the rest of post-init } } // ───────────────────────────────────────────────────────────────────────── // Helpers // ───────────────────────────────────────────────────────────────────────── private static string GeneratePassword(int length) { const string chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*"; return RandomNumberGenerator.GetString(chars, length); } // ───────────────────────────────────────────────────────────────────────── // Import existing instance secrets on startup // ───────────────────────────────────────────────────────────────────────── /// /// Scans all Bitwarden secrets for existing instance-level credentials /// (matching the {abbrev}/xibo-admin-password and {abbrev}/xibo-oauth-secret /// naming convention) and imports their mappings into the config settings so /// the app knows about them without a manual re-provisioning step. /// Safe to call on every startup — existing mappings are never overwritten. /// public async Task ImportExistingInstanceSecretsAsync() { try { using var scope = _services.CreateScope(); var bws = scope.ServiceProvider.GetRequiredService(); var settings = scope.ServiceProvider.GetRequiredService(); if (!await bws.IsConfiguredAsync()) { _logger.LogDebug("[Import] Bitwarden not configured — skipping instance secret import"); return; } var allSecrets = await bws.ListSecretsAsync(); var imported = 0; foreach (var summary in allSecrets) { // ── Admin password pattern: {abbrev}/xibo-admin-password ── var adminMatch = Regex.Match(summary.Key, @"^([a-zA-Z0-9_-]+)/xibo-admin-password$"); if (adminMatch.Success) { var abbrev = adminMatch.Groups[1].Value; var existing = await settings.GetAsync(SettingsService.InstanceAdminPasswordSecretId(abbrev)); if (string.IsNullOrWhiteSpace(existing)) { await settings.SetAsync( SettingsService.InstanceAdminPasswordSecretId(abbrev), summary.Id, SettingsService.CatInstance); imported++; _logger.LogInformation( "[Import] Imported admin password secret for instance {Abbrev} (id={Id})", abbrev, summary.Id); } continue; } // ── OAuth secret pattern: {abbrev}/xibo-oauth-secret ── var oauthMatch = Regex.Match(summary.Key, @"^([a-zA-Z0-9_-]+)/xibo-oauth-secret$"); if (oauthMatch.Success) { var abbrev = oauthMatch.Groups[1].Value; var existing = await settings.GetAsync(SettingsService.InstanceOAuthSecretId(abbrev)); if (string.IsNullOrWhiteSpace(existing)) { await settings.SetAsync( SettingsService.InstanceOAuthSecretId(abbrev), summary.Id, SettingsService.CatInstance); imported++; _logger.LogInformation( "[Import] Imported OAuth secret for instance {Abbrev} (id={Id})", abbrev, summary.Id); // Try to extract the OAuth client_id from the secret's Note field try { var full = await bws.GetSecretAsync(summary.Id); var cidMatch = Regex.Match(full.Note ?? "", @"ClientId:\s*(\S+)"); if (cidMatch.Success) { var existingCid = await settings.GetAsync( SettingsService.InstanceOAuthClientId(abbrev)); if (string.IsNullOrWhiteSpace(existingCid)) { await settings.SetAsync( SettingsService.InstanceOAuthClientId(abbrev), cidMatch.Groups[1].Value, SettingsService.CatInstance); _logger.LogInformation( "[Import] Imported OAuth client ID for instance {Abbrev}", abbrev); } } } catch (Exception ex) { _logger.LogWarning(ex, "[Import] Could not fetch full OAuth secret for {Abbrev} to extract client ID", abbrev); } } } } if (imported > 0) _logger.LogInformation("[Import] Imported {Count} instance secret mapping(s) from Bitwarden", imported); else _logger.LogDebug("[Import] No new instance secrets to import"); } catch (Exception ex) { _logger.LogError(ex, "[Import] Failed to import existing instance secrets from Bitwarden"); } } } /// 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); }