feat: Implement container logs functionality in InstancesViewModel
- Added properties for managing container logs, including log entries, service filters, and auto-refresh options. - Introduced commands for refreshing logs, toggling auto-refresh, and closing the logs panel. - Implemented log fetching logic with error handling and status messages. - Integrated log display in the InstancesView with a dedicated logs panel. feat: Enhance navigation to Instances page with auto-selection - Added method to navigate to the Instances page and auto-select an instance based on abbreviation. feat: Update SettingsViewModel to load and save Bitwarden configuration - Integrated Bitwarden configuration loading from IOptions and saving to appsettings.json. - Added properties for Bitwarden instance project ID and connection status. - Updated UI to reflect Bitwarden settings and connection status. feat: Add advanced options for instance creation - Introduced a new expander in CreateInstanceView for advanced options, including purging stale volumes. feat: Improve InstanceDetailsWindow with pending setup banner - Added a banner to indicate pending setup for Xibo OAuth credentials, with editable fields for client ID and secret. fix: Update appsettings.json to include Bitwarden configuration structure - Added Bitwarden section to appsettings.json for storing configuration values. chore: Update Docker Compose template with health checks - Added health check configuration for web service in template.yml to ensure service availability. refactor: Drop AppSettings table from database - Removed AppSettings table and related migration files as part of database cleanup. feat: Create ServiceLogEntry DTO for log management - Added ServiceLogEntry class to represent individual log entries from Docker services.
This commit is contained in:
@@ -1,8 +1,7 @@
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OTSSignsOrchestrator.Core.Data;
|
||||
|
||||
namespace OTSSignsOrchestrator.Core.Services;
|
||||
|
||||
@@ -10,15 +9,22 @@ namespace OTSSignsOrchestrator.Core.Services;
|
||||
/// 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>
|
||||
///
|
||||
/// Invoked as a background fire-and-forget task from <see cref="InstanceService.CreateInstanceAsync"/>.
|
||||
/// 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;
|
||||
@@ -41,9 +47,14 @@ public class PostInstanceInitService
|
||||
|
||||
/// <summary>
|
||||
/// Executes the post-instance initialisation sequence.
|
||||
/// Intended to be called as a background task after stack deployment succeeds.
|
||||
/// Called from the UI after the user supplies OAuth2 client credentials.
|
||||
/// </summary>
|
||||
public async Task RunAsync(string abbrev, string instanceUrl, CancellationToken ct = default)
|
||||
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);
|
||||
|
||||
@@ -53,24 +64,6 @@ public class PostInstanceInitService
|
||||
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);
|
||||
@@ -79,74 +72,158 @@ public class PostInstanceInitService
|
||||
throw new TimeoutException(
|
||||
$"Xibo instance at {instanceUrl} did not become ready within {XiboReadinessTimeout.TotalMinutes} minutes.");
|
||||
|
||||
// ── 2. Generate credentials ───────────────────────────────────────
|
||||
// ── 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";
|
||||
|
||||
// ── 3. Create OTS admin user ──────────────────────────────────────
|
||||
// ── 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, bootstrapClientId, bootstrapClientSecret,
|
||||
adminUsername, adminPassword, adminEmail);
|
||||
instanceUrl, accessToken,
|
||||
adminUsername, adminPassword, adminEmail, adminGroupId);
|
||||
|
||||
// ── 4. Register dedicated OAuth2 application ──────────────────────
|
||||
// ── 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 (oauthClientId, oauthClientSecret) = await xibo.RegisterOAuthClientAsync(
|
||||
instanceUrl, bootstrapClientId, bootstrapClientSecret,
|
||||
appName: $"OTS Signs — {abbrev.ToUpperInvariant()}");
|
||||
var (otsClientId, otsClientSecret) = await xibo.RegisterOAuthClientAsync(
|
||||
instanceUrl, accessToken,
|
||||
$"OTS Signs — {abbrev.ToUpperInvariant()}");
|
||||
|
||||
// ── 5. Set theme ──────────────────────────────────────────────────
|
||||
// ── 6. Set theme ──────────────────────────────────────────────────
|
||||
_logger.LogInformation("[PostInit] Setting Xibo theme to 'otssigns'");
|
||||
await xibo.SetThemeAsync(instanceUrl, bootstrapClientId, bootstrapClientSecret, "otssigns");
|
||||
await xibo.SetThemeAsync(instanceUrl, accessToken, "otssigns");
|
||||
|
||||
// ── 6. Store credentials in Bitwarden ─────────────────────────────
|
||||
if (await bws.IsConfiguredAsync())
|
||||
{
|
||||
_logger.LogInformation("[PostInit] Storing credentials in Bitwarden");
|
||||
// ── 7. Store credentials in Bitwarden ─────────────────────────────
|
||||
_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 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.CreateSecretAsync(
|
||||
key: $"{abbrev}/xibo-oauth-secret",
|
||||
value: oauthClientSecret,
|
||||
note: $"Xibo CMS OAuth2 client secret for OTS application. ClientId: {oauthClientId}");
|
||||
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 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.");
|
||||
// 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);
|
||||
|
||||
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);
|
||||
// ── 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, oauthClientId);
|
||||
abbrev, adminUsername, otsClientId);
|
||||
}
|
||||
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.
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,59 +242,50 @@ public class PostInstanceInitService
|
||||
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);
|
||||
// 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?");
|
||||
|
||||
await xibo.RotateUserPasswordAsync(instanceUrl, bootstrapClientId, bootstrapClientSecret, userId, newPassword);
|
||||
var oauthSecret = await bws.GetSecretAsync(oauthSecretId);
|
||||
var accessToken = await xibo.LoginAsync(instanceUrl, oauthClientId, oauthSecret.Value);
|
||||
|
||||
// Update Bitwarden secret if available
|
||||
if (await bws.IsConfiguredAsync())
|
||||
// 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))
|
||||
{
|
||||
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);
|
||||
}
|
||||
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
|
||||
{
|
||||
// Fallback: encrypted AppSettings
|
||||
await settings.SetAsync($"Instance.{abbrev}.AdminPassword", newPassword,
|
||||
SettingsService.CatInstance, isSensitive: true);
|
||||
// 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);
|
||||
}
|
||||
|
||||
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).
|
||||
/// Returns the stored admin password for an instance from Bitwarden.
|
||||
/// </summary>
|
||||
public async Task<InstanceCredentials> GetCredentialsAsync(string abbrev)
|
||||
{
|
||||
@@ -230,39 +298,32 @@ public class PostInstanceInitService
|
||||
string? adminPassword = null;
|
||||
string? oauthClientSecret = null;
|
||||
|
||||
if (await bws.IsConfiguredAsync())
|
||||
var adminSecretId = await settings.GetAsync(SettingsService.InstanceAdminPasswordSecretId(abbrev));
|
||||
if (!string.IsNullOrWhiteSpace(adminSecretId))
|
||||
{
|
||||
var adminSecretId = await settings.GetAsync(SettingsService.InstanceAdminPasswordSecretId(abbrev));
|
||||
if (!string.IsNullOrWhiteSpace(adminSecretId))
|
||||
try
|
||||
{
|
||||
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 secret = await bws.GetSecretAsync(adminSecretId);
|
||||
adminPassword = secret.Value;
|
||||
}
|
||||
|
||||
var oauthSecretId = await settings.GetAsync(SettingsService.InstanceOAuthSecretId(abbrev));
|
||||
if (!string.IsNullOrWhiteSpace(oauthSecretId))
|
||||
catch (Exception ex)
|
||||
{
|
||||
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);
|
||||
}
|
||||
_logger.LogWarning(ex, "Could not retrieve admin password from Bitwarden for {Abbrev}", abbrev);
|
||||
}
|
||||
}
|
||||
else
|
||||
|
||||
var oauthSecretId = await settings.GetAsync(SettingsService.InstanceOAuthSecretId(abbrev));
|
||||
if (!string.IsNullOrWhiteSpace(oauthSecretId))
|
||||
{
|
||||
adminPassword = await settings.GetAsync($"Instance.{abbrev}.AdminPassword");
|
||||
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
|
||||
@@ -284,50 +345,109 @@ public class PostInstanceInitService
|
||||
return RandomNumberGenerator.GetString(chars, length);
|
||||
}
|
||||
|
||||
private static async Task<int> GetXiboUserIdAsync(
|
||||
XiboApiService xibo,
|
||||
string instanceUrl,
|
||||
string clientId,
|
||||
string clientSecret,
|
||||
string targetUsername)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// 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()
|
||||
{
|
||||
// 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[]
|
||||
try
|
||||
{
|
||||
new KeyValuePair<string, string>("grant_type", "client_credentials"),
|
||||
new KeyValuePair<string, string>("client_id", clientId),
|
||||
new KeyValuePair<string, string>("client_secret", clientSecret),
|
||||
});
|
||||
using var scope = _services.CreateScope();
|
||||
var bws = scope.ServiceProvider.GetRequiredService<IBitwardenSecretService>();
|
||||
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
|
||||
|
||||
var tokenResp = await http.PostAsync($"{baseUrl}/api/authorize/access_token", form);
|
||||
tokenResp.EnsureSuccessStatusCode();
|
||||
if (!await bws.IsConfiguredAsync())
|
||||
{
|
||||
_logger.LogDebug("[Import] Bitwarden not configured — skipping instance secret import");
|
||||
return;
|
||||
}
|
||||
|
||||
using var tokenDoc = await System.Text.Json.JsonDocument.ParseAsync(
|
||||
await tokenResp.Content.ReadAsStreamAsync());
|
||||
var token = tokenDoc.RootElement.GetProperty("access_token").GetString()!;
|
||||
var allSecrets = await bws.ListSecretsAsync();
|
||||
var imported = 0;
|
||||
|
||||
http.DefaultRequestHeaders.Authorization =
|
||||
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
|
||||
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;
|
||||
}
|
||||
|
||||
var usersResp = await http.GetAsync($"{baseUrl}/api/user?userName={Uri.EscapeDataString(targetUsername)}");
|
||||
usersResp.EnsureSuccessStatusCode();
|
||||
// ── 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);
|
||||
|
||||
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();
|
||||
// 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");
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"Xibo user '{targetUsername}' not found. Post-instance init may not have completed yet.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user