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"); // ── 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"); // ── 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. /// The template is resolved from (a) the git repo cache, or (b) the local bundled /// templates/ directory shipped with the application. /// Errors are logged but do not fail the overall deployment. /// public async Task DeploySamlConfigurationAsync( string abbrev, string instanceUrl, SettingsService settings, string? customerName = null, CancellationToken ct = default) { 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. Locate settings-custom.php.template ──────────────────────── string? templateContent = null; // 1a. Try git repo cache first var repoUrl = await settings.GetAsync(SettingsService.GitRepoUrl); var repoPat = await settings.GetAsync(SettingsService.GitRepoPat); if (!string.IsNullOrWhiteSpace(repoUrl)) { try { var templateConfig = await git.FetchAsync(repoUrl, repoPat); var gitPath = Path.Combine(templateConfig.CacheDir, "settings-custom.php.template"); if (File.Exists(gitPath)) { templateContent = await File.ReadAllTextAsync(gitPath, ct); _logger.LogInformation("[PostInit] Using template from git repo cache: {Path}", gitPath); } } catch (Exception ex) { _logger.LogWarning(ex, "[PostInit] Could not fetch template from git — trying local fallback"); } } // 1b. Fall back to local templates/ directory (bundled with app) if (templateContent == null) { var candidates = new[] { Path.Combine(AppContext.BaseDirectory, "templates", "settings-custom.php.template"), Path.Combine(Directory.GetCurrentDirectory(), "templates", "settings-custom.php.template"), }; foreach (var candidate in candidates) { if (File.Exists(candidate)) { templateContent = await File.ReadAllTextAsync(candidate, ct); _logger.LogInformation("[PostInit] Using local template: {Path}", candidate); break; } } } if (templateContent == null) { _logger.LogWarning( "[PostInit] settings-custom.php.template not found in git repo or local templates/ — skipping SAML deployment"); return; } // ── 2. Provision Authentik SAML application ─────────────────────── var samlBaseUrl = instanceUrl.TrimEnd('/') + "/saml"; Models.DTOs.AuthentikSamlConfig? samlConfig = null; try { samlConfig = await authentik.ProvisionSamlAsync(abbrev, instanceUrl, ct); } catch (Exception ex) { _logger.LogWarning(ex, "[PostInit] Authentik provisioning failed for {Abbrev} — skipping SAML config deployment to avoid broken Xibo instance", abbrev); return; // Do NOT write a settings-custom.php with empty IdP values — it will crash Xibo } // ── 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}{ProviderInfo}", abbrev, samlConfig != null ? $" (Authentik provider={samlConfig.ProviderId})" : " (without Authentik — needs manual IdP config)"); // ── 5. Sync Authentik groups to Xibo ────────────────────────────── // Pre-create Authentik groups as Xibo user groups so they're available // immediately (before any user logs in via SSO). await SyncGroupsFromAuthentikAsync(abbrev, instanceUrl, settings, ct); // ── 6. Set up customer invitation infrastructure in Authentik ───── // Creates a group, enrollment flow, invitation stage, role, and // scoping policies so the customer admin can invite users directly. await SetupCustomerInvitationInfrastructureAsync(abbrev, customerName, ct); } 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 } } /// /// Sets up the customer invitation infrastructure in Authentik (group, enrollment flow, /// stages, role, and policies). Errors are logged but do not block other operations. /// private async Task SetupCustomerInvitationInfrastructureAsync( string abbrev, string? customerName, CancellationToken ct) { try { using var scope = _services.CreateScope(); var invitationSetup = scope.ServiceProvider.GetRequiredService(); var displayName = !string.IsNullOrWhiteSpace(customerName) ? customerName : abbrev.ToUpperInvariant(); _logger.LogInformation("[PostInit] Setting up invitation infrastructure for {Abbrev}", abbrev); var result = await invitationSetup.SetupCustomerInvitationAsync(abbrev, displayName, ct); if (result.Success) { _logger.LogInformation( "[PostInit] Invitation infrastructure ready for {Abbrev}: group={Group}, flow={Flow}, role={Role}", abbrev, result.GroupName, result.EnrollmentFlowSlug, result.RoleName); if (!string.IsNullOrWhiteSpace(result.InvitationManagementUrl)) { _logger.LogInformation( "[PostInit] Customer admin invitation URL: {Url}", result.InvitationManagementUrl); } } else { _logger.LogWarning("[PostInit] Invitation setup reported failure for {Abbrev}: {Message}", abbrev, result.Message); } } catch (Exception ex) { _logger.LogError(ex, "[PostInit] Invitation infrastructure setup failed for {Abbrev}: {Message}. " + "Customer invitations can be configured manually in Authentik.", abbrev, ex.Message); // Don't rethrow — invitation setup failure should not block post-init } } /// /// Fetches all groups from Authentik and creates matching user groups in the /// specified Xibo instance, excluding any groups listed in the /// "SamlGroupSyncExcludedGroups" setting (comma-separated group names). /// Groups that already exist in Xibo are skipped. /// This ensures that groups are available in Xibo for permission assignment /// before any user logs in via SAML SSO. /// public async Task SyncGroupsFromAuthentikAsync( string abbrev, string instanceUrl, SettingsService settings, CancellationToken ct = default) { var synced = 0; try { _logger.LogInformation("[GroupSync] Syncing Authentik groups to Xibo for {Abbrev}", abbrev); using var scope = _services.CreateScope(); var authentik = scope.ServiceProvider.GetRequiredService(); var xibo = scope.ServiceProvider.GetRequiredService(); // ── 1. Fetch groups from Authentik ──────────────────────────────── var authentikGroups = await authentik.ListGroupsAsync(ct: ct); if (authentikGroups.Count == 0) { _logger.LogInformation("[GroupSync] No groups found in Authentik — nothing to sync"); return 0; } _logger.LogInformation("[GroupSync] Found {Count} Authentik group(s) to sync", authentikGroups.Count); // ── 1b. Read excluded groups from settings ──────────────────────── var excludedGroupsSetting = await settings.GetAsync("SamlGroupSyncExcludedGroups"); var excludedGroups = new HashSet(StringComparer.OrdinalIgnoreCase); if (!string.IsNullOrWhiteSpace(excludedGroupsSetting)) { var excluded = excludedGroupsSetting .Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries) .Select(g => g.Trim()) .Where(g => !string.IsNullOrWhiteSpace(g)); foreach (var g in excluded) { excludedGroups.Add(g); } _logger.LogInformation("[GroupSync] Excluded groups: {Groups}", string.Join(", ", excludedGroups)); } // ── 2. Authenticate to Xibo ─────────────────────────────────────── var oauthClientId = await settings.GetAsync(SettingsService.InstanceOAuthClientId(abbrev)); var oauthSecretId = await settings.GetAsync(SettingsService.InstanceOAuthSecretId(abbrev)); if (string.IsNullOrWhiteSpace(oauthClientId) || string.IsNullOrWhiteSpace(oauthSecretId)) { _logger.LogWarning("[GroupSync] No OAuth credentials for {Abbrev} — cannot sync groups", abbrev); return 0; } var bws = scope.ServiceProvider.GetRequiredService(); var oauthSecret = await bws.GetSecretAsync(oauthSecretId); var accessToken = await xibo.LoginAsync(instanceUrl, oauthClientId, oauthSecret.Value); // ── 3. List existing Xibo groups ────────────────────────────────── var existingGroups = await xibo.ListUserGroupsAsync(instanceUrl, accessToken); var existingNames = new HashSet( existingGroups.Select(g => g.Group), StringComparer.OrdinalIgnoreCase); // ── 4. Create missing groups in Xibo (excluding specified ones) ──── foreach (var group in authentikGroups) { // Skip excluded groups if (excludedGroups.Contains(group.Name)) { _logger.LogInformation("[GroupSync] Skipping excluded group '{Name}'", group.Name); continue; } if (existingNames.Contains(group.Name)) { _logger.LogDebug("[GroupSync] Group '{Name}' already exists in Xibo", group.Name); continue; } try { var groupId = await xibo.CreateUserGroupAsync(instanceUrl, accessToken, group.Name); _logger.LogInformation("[GroupSync] Created Xibo group '{Name}' (id={Id})", group.Name, groupId); synced++; } catch (Exception ex) { _logger.LogWarning(ex, "[GroupSync] Failed to create Xibo group '{Name}'", group.Name); } } _logger.LogInformation("[GroupSync] Sync complete for {Abbrev}: {Synced} group(s) created", abbrev, synced); } catch (Exception ex) { _logger.LogError(ex, "[GroupSync] Group sync failed for {Abbrev}: {Message}", abbrev, ex.Message); // Don't rethrow — group sync failure should not block other operations } return synced; } // ───────────────────────────────────────────────────────────────────────── // 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); }