- Introduced InstanceDetailsViewModel to handle loading and displaying instance-specific credentials. - Created InstanceDetailsWindow and associated XAML for displaying admin, database, and OAuth2 credentials. - Updated InstancesViewModel to include command for opening instance details. - Enhanced SettingsViewModel to manage Bitwarden and Xibo Bootstrap configurations, including connection testing. - Added UI components for Bitwarden Secrets Manager and Xibo Bootstrap OAuth2 settings in the SettingsView. - Implemented password visibility toggles and clipboard copy functionality for sensitive information.
346 lines
18 KiB
C#
346 lines
18 KiB
C#
using System.Security.Cryptography;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Logging;
|
|
using OTSSignsOrchestrator.Core.Data;
|
|
|
|
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>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>
|
|
/// </list>
|
|
///
|
|
/// Invoked as a background fire-and-forget task from <see cref="InstanceService.CreateInstanceAsync"/>.
|
|
/// Progress is tracked in the operation log; failures are logged but do not roll back the deployment.
|
|
/// </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.
|
|
/// Intended to be called as a background task after stack deployment succeeds.
|
|
/// </summary>
|
|
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<XiboApiService>();
|
|
var bws = scope.ServiceProvider.GetRequiredService<IBitwardenSecretService>();
|
|
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
|
|
var db = scope.ServiceProvider.GetRequiredService<XiboContext>();
|
|
|
|
// ── 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)
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
/// <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 db = scope.ServiceProvider.GetRequiredService<XiboContext>();
|
|
|
|
// 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the stored admin password for an instance (from Bitwarden or local AppSettings).
|
|
/// </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;
|
|
|
|
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<int> 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<string, string>("grant_type", "client_credentials"),
|
|
new KeyValuePair<string, string>("client_id", clientId),
|
|
new KeyValuePair<string, string>("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.");
|
|
}
|
|
}
|
|
|
|
/// <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);
|
|
}
|