Files
OTSSignsOrchestrator/OTSSignsOrchestrator.Core/Services/PostInstanceInitService.cs
Matt Batchelder a1c987ff21 feat: Add Instance Details ViewModel and UI for managing instance credentials
- 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.
2026-02-25 08:05:44 -05:00

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);
}