- Removed SAML configuration deployment calls from PostInstanceInitService. - Updated DeploySamlConfigurationAsync to improve template fetching logic from Git and local directories. - Added Authentik flow and keypair models for better representation in the UI. - Enhanced SettingsViewModel to include Authentik settings with save and test functionality. - Updated UI to support Authentik configuration, including dropdowns for flows and keypairs. - Changed default CMS server name template to "app.ots-signs.com" across various files. - Improved password handling in SshDockerCliService for secure shell command execution. - Added new template file for settings-custom.php in the project structure.
600 lines
32 KiB
C#
600 lines
32 KiB
C#
using System.Security.Cryptography;
|
|
using System.Text.RegularExpressions;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Logging;
|
|
using OTSSignsOrchestrator.Core.Configuration;
|
|
|
|
namespace OTSSignsOrchestrator.Core.Services;
|
|
|
|
/// <summary>
|
|
/// Runs once after a Xibo CMS stack is deployed to complete post-install setup:
|
|
/// <list type="number">
|
|
/// <item>Waits for the Xibo web service to become available.</item>
|
|
/// <item>Authenticates using the OAuth2 application credentials supplied by the user.</item>
|
|
/// <item>Creates the OTS admin user with a random password.</item>
|
|
/// <item>Registers a dedicated client_credentials OAuth2 application for OTS.</item>
|
|
/// <item>Activates the <c>otssigns</c> theme.</item>
|
|
/// <item>Stores all generated credentials in Bitwarden Secrets Manager.</item>
|
|
/// <item>Deletes the default <c>xibo_admin</c> account.</item>
|
|
/// </list>
|
|
///
|
|
/// The user must first create an OAuth2 application (client_credentials) in the
|
|
/// Xibo web UI using the default <c>xibo_admin / password</c> 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.
|
|
/// </summary>
|
|
/// </summary>
|
|
public class PostInstanceInitService
|
|
{
|
|
private readonly IServiceProvider _services;
|
|
private readonly ILogger<PostInstanceInitService> _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<PostInstanceInitService> logger)
|
|
{
|
|
_services = services;
|
|
_logger = logger;
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Entry point
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Executes the post-instance initialisation sequence.
|
|
/// Called from the UI after the user supplies OAuth2 client credentials.
|
|
/// </summary>
|
|
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<XiboApiService>();
|
|
var bws = scope.ServiceProvider.GetRequiredService<IBitwardenSecretService>();
|
|
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
|
|
|
|
// ── 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)
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Initialises a Xibo CMS instance using OAuth2 credentials supplied by the user.
|
|
/// Unlike <see cref="RunAsync"/>, 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.
|
|
/// </summary>
|
|
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<XiboApiService>();
|
|
var bws = scope.ServiceProvider.GetRequiredService<IBitwardenSecretService>();
|
|
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
|
|
|
|
// ── 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)
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Generates a new OTS admin password, updates it in Xibo, and rotates the Bitwarden secret.
|
|
/// </summary>
|
|
public async Task<string> RotateAdminPasswordAsync(string abbrev, string instanceUrl)
|
|
{
|
|
_logger.LogInformation("[PostInit] Rotating admin password for {Abbrev}", abbrev);
|
|
|
|
using var scope = _services.CreateScope();
|
|
var xibo = scope.ServiceProvider.GetRequiredService<XiboApiService>();
|
|
var bws = scope.ServiceProvider.GetRequiredService<IBitwardenSecretService>();
|
|
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
|
|
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the stored admin password for an instance from Bitwarden.
|
|
/// </summary>
|
|
public async Task<InstanceCredentials> GetCredentialsAsync(string abbrev)
|
|
{
|
|
using var scope = _services.CreateScope();
|
|
var bws = scope.ServiceProvider.GetRequiredService<IBitwardenSecretService>();
|
|
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
|
|
|
|
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
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// 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
|
|
/// <c>templates/</c> directory shipped with the application.
|
|
/// Errors are logged but do not fail the overall deployment.
|
|
/// </summary>
|
|
public 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<IAuthentikService>();
|
|
var git = scope.ServiceProvider.GetRequiredService<GitTemplateService>();
|
|
var docker = scope.ServiceProvider.GetRequiredService<IDockerCliService>();
|
|
|
|
// ── 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)");
|
|
}
|
|
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
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Scans all Bitwarden secrets for existing instance-level credentials
|
|
/// (matching the <c>{abbrev}/xibo-admin-password</c> and <c>{abbrev}/xibo-oauth-secret</c>
|
|
/// 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.
|
|
/// </summary>
|
|
public async Task ImportExistingInstanceSecretsAsync()
|
|
{
|
|
try
|
|
{
|
|
using var scope = _services.CreateScope();
|
|
var bws = scope.ServiceProvider.GetRequiredService<IBitwardenSecretService>();
|
|
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
|
|
|
|
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");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>Snapshot of provisioned credentials for a CMS instance.</summary>
|
|
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);
|
|
}
|