From a1c987ff21643d17465c58b5c858460f32f64fc9 Mon Sep 17 00:00:00 2001 From: Matt Batchelder Date: Wed, 25 Feb 2026 08:05:44 -0500 Subject: [PATCH] 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. --- .../Services/BitwardenSecretService.cs | 255 +++++++++++++ .../Services/IBitwardenSecretService.cs | 49 +++ .../Services/InstanceService.cs | 10 +- .../Services/PostInstanceInitService.cs | 345 ++++++++++++++++++ .../Services/SettingsService.cs | 21 +- .../Services/XiboApiService.cs | 323 ++++++++++++++-- OTSSignsOrchestrator.Desktop/App.axaml.cs | 5 + .../ViewModels/InstanceDetailsViewModel.cs | 266 ++++++++++++++ .../ViewModels/InstancesViewModel.cs | 28 ++ .../ViewModels/SettingsViewModel.cs | 91 +++++ .../Views/InstanceDetailsWindow.axaml | 146 ++++++++ .../Views/InstanceDetailsWindow.axaml.cs | 11 + .../Views/InstancesView.axaml | 3 + .../Views/InstancesView.axaml.cs | 26 ++ .../Views/SettingsView.axaml | 71 ++++ otssigns-desktop.db-shm | Bin 0 -> 32768 bytes otssigns-desktop.db-wal | Bin 0 -> 32992 bytes 17 files changed, 1608 insertions(+), 42 deletions(-) create mode 100644 OTSSignsOrchestrator.Core/Services/BitwardenSecretService.cs create mode 100644 OTSSignsOrchestrator.Core/Services/IBitwardenSecretService.cs create mode 100644 OTSSignsOrchestrator.Core/Services/PostInstanceInitService.cs create mode 100644 OTSSignsOrchestrator.Desktop/ViewModels/InstanceDetailsViewModel.cs create mode 100644 OTSSignsOrchestrator.Desktop/Views/InstanceDetailsWindow.axaml create mode 100644 OTSSignsOrchestrator.Desktop/Views/InstanceDetailsWindow.axaml.cs create mode 100644 otssigns-desktop.db-shm create mode 100644 otssigns-desktop.db-wal diff --git a/OTSSignsOrchestrator.Core/Services/BitwardenSecretService.cs b/OTSSignsOrchestrator.Core/Services/BitwardenSecretService.cs new file mode 100644 index 0000000..d179698 --- /dev/null +++ b/OTSSignsOrchestrator.Core/Services/BitwardenSecretService.cs @@ -0,0 +1,255 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; + +namespace OTSSignsOrchestrator.Core.Services; + +/// +/// Stores and retrieves secrets from Bitwarden Secrets Manager (machine account API). +/// +/// Configuration required in Settings: +/// Bitwarden.IdentityUrl – defaults to https://identity.bitwarden.com +/// Bitwarden.ApiUrl – defaults to https://api.bitwarden.com +/// Bitwarden.AccessToken – machine account access token (sensitive) +/// Bitwarden.OrganizationId – Bitwarden organisation that owns the project +/// Bitwarden.ProjectId – project where new secrets are created +/// +public class BitwardenSecretService : IBitwardenSecretService +{ + private readonly IHttpClientFactory _http; + private readonly SettingsService _settings; + private readonly ILogger _logger; + + // Cached bearer token (refreshed per service lifetime — transient registration is assumed) + private string? _bearerToken; + + public BitwardenSecretService( + IHttpClientFactory http, + SettingsService settings, + ILogger logger) + { + _http = http; + _settings = settings; + _logger = logger; + } + + // ───────────────────────────────────────────────────────────────────────── + // IBitwardenSecretService + // ───────────────────────────────────────────────────────────────────────── + + public async Task IsConfiguredAsync() + { + var token = await _settings.GetAsync(SettingsService.BitwardenAccessToken); + var orgId = await _settings.GetAsync(SettingsService.BitwardenOrganizationId); + return !string.IsNullOrWhiteSpace(token) && !string.IsNullOrWhiteSpace(orgId); + } + + public async Task CreateSecretAsync(string key, string value, string note = "") + { + var bearer = await GetBearerTokenAsync(); + var projectId = await _settings.GetAsync(SettingsService.BitwardenProjectId); + var orgId = await _settings.GetAsync(SettingsService.BitwardenOrganizationId) + ?? throw new InvalidOperationException("Bitwarden OrganizationId is not configured."); + var apiUrl = await GetApiUrlAsync(); + + var client = CreateClient(bearer); + var body = new + { + organizationId = orgId, + projectIds = projectId is not null ? new[] { projectId } : Array.Empty(), + key = key, + value = value, + note = note + }; + + var response = await client.PostAsJsonAsync($"{apiUrl}/secrets", body); + await EnsureSuccessAsync(response, "create secret"); + + var result = await response.Content.ReadFromJsonAsync() + ?? throw new InvalidOperationException("Empty response from Bitwarden API."); + + _logger.LogInformation("Bitwarden secret created: key={Key}, id={Id}", key, result.Id); + return result.Id; + } + + public async Task GetSecretAsync(string secretId) + { + var bearer = await GetBearerTokenAsync(); + var apiUrl = await GetApiUrlAsync(); + var client = CreateClient(bearer); + + var response = await client.GetAsync($"{apiUrl}/secrets/{secretId}"); + await EnsureSuccessAsync(response, "get secret"); + + var result = await response.Content.ReadFromJsonAsync() + ?? throw new InvalidOperationException("Empty response from Bitwarden API."); + + return new BitwardenSecret + { + Id = result.Id, + Key = result.Key, + Value = result.Value, + Note = result.Note ?? string.Empty, + CreationDate = result.CreationDate + }; + } + + public async Task UpdateSecretAsync(string secretId, string key, string value, string note = "") + { + var bearer = await GetBearerTokenAsync(); + var projectId = await _settings.GetAsync(SettingsService.BitwardenProjectId); + var orgId = await _settings.GetAsync(SettingsService.BitwardenOrganizationId) + ?? throw new InvalidOperationException("Bitwarden OrganizationId is not configured."); + var apiUrl = await GetApiUrlAsync(); + + var client = CreateClient(bearer); + var body = new + { + organizationId = orgId, + projectIds = projectId is not null ? new[] { projectId } : Array.Empty(), + key = key, + value = value, + note = note + }; + + var response = await client.PutAsJsonAsync($"{apiUrl}/secrets/{secretId}", body); + await EnsureSuccessAsync(response, "update secret"); + + _logger.LogInformation("Bitwarden secret updated: key={Key}, id={Id}", key, secretId); + } + + public async Task> ListSecretsAsync() + { + var bearer = await GetBearerTokenAsync(); + var orgId = await _settings.GetAsync(SettingsService.BitwardenOrganizationId) + ?? throw new InvalidOperationException("Bitwarden OrganizationId is not configured."); + var apiUrl = await GetApiUrlAsync(); + var client = CreateClient(bearer); + + var response = await client.GetAsync($"{apiUrl}/organizations/{orgId}/secrets"); + await EnsureSuccessAsync(response, "list secrets"); + + var result = await response.Content.ReadFromJsonAsync(); + + return result?.Data?.Select(s => new BitwardenSecretSummary + { + Id = s.Id, + Key = s.Key, + CreationDate = s.CreationDate + }).ToList() ?? new List(); + } + + // ───────────────────────────────────────────────────────────────────────── + // Auth + // ───────────────────────────────────────────────────────────────────────── + + /// + /// Exchanges the machine-account access token for a short-lived Bearer token. + /// The access token format is: 0.{tokenId}.{clientSecret}:{encKeyB64} + /// The client_id used is "machine.{tokenId}". + /// + private async Task GetBearerTokenAsync() + { + if (_bearerToken is not null) + return _bearerToken; + + var rawToken = await _settings.GetAsync(SettingsService.BitwardenAccessToken) + ?? throw new InvalidOperationException("Bitwarden AccessToken is not configured in Settings."); + var identityUrl = await GetIdentityUrlAsync(); + + // Parse token: "0.." — split off the first two segments + var parts = rawToken.Split('.', 3); + if (parts.Length < 3) + throw new FormatException( + "Bitwarden access token has unexpected format. Expected: 0.."); + + var tokenId = parts[1]; + var clientSecret = parts[2]; // may contain ":base64key" suffix — include all of it + + var client = _http.CreateClient("Bitwarden"); + var form = new FormUrlEncodedContent(new[] + { + new KeyValuePair("grant_type", "client_credentials"), + new KeyValuePair("client_id", $"machine.{tokenId}"), + new KeyValuePair("client_secret", clientSecret), + new KeyValuePair("scope", "api.secrets"), + }); + + var response = await client.PostAsync($"{identityUrl}/connect/token", form); + await EnsureSuccessAsync(response, "authenticate with Bitwarden identity"); + + var token = await response.Content.ReadFromJsonAsync() + ?? throw new InvalidOperationException("Empty token response from Bitwarden."); + + _bearerToken = token.AccessToken; + _logger.LogInformation("Bitwarden bearer token acquired."); + return _bearerToken; + } + + // ───────────────────────────────────────────────────────────────────────── + // Helpers + // ───────────────────────────────────────────────────────────────────────── + + private async Task GetIdentityUrlAsync() + => (await _settings.GetAsync(SettingsService.BitwardenIdentityUrl, "https://identity.bitwarden.com")).TrimEnd('/'); + + private async Task GetApiUrlAsync() + => (await _settings.GetAsync(SettingsService.BitwardenApiUrl, "https://api.bitwarden.com")).TrimEnd('/'); + + private static HttpClient CreateClient(string bearerToken) + { + var client = new HttpClient(); + client.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", bearerToken); + return client; + } + + private static async Task EnsureSuccessAsync(HttpResponseMessage response, string operation) + { + if (!response.IsSuccessStatusCode) + { + var body = await response.Content.ReadAsStringAsync(); + throw new HttpRequestException( + $"Bitwarden API call '{operation}' failed: {(int)response.StatusCode} {response.ReasonPhrase} — {body}"); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // Internal DTOs + // ───────────────────────────────────────────────────────────────────────── + + private sealed class BwsTokenResponse + { + [JsonPropertyName("access_token")] + public string AccessToken { get; set; } = string.Empty; + } + + private sealed class BwsSecretResponse + { + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + [JsonPropertyName("organizationId")] + public string OrganizationId { get; set; } = string.Empty; + + [JsonPropertyName("key")] + public string Key { get; set; } = string.Empty; + + [JsonPropertyName("value")] + public string Value { get; set; } = string.Empty; + + [JsonPropertyName("note")] + public string? Note { get; set; } + + [JsonPropertyName("creationDate")] + public DateTime CreationDate { get; set; } + } + + private sealed class BwsSecretsListResponse + { + [JsonPropertyName("data")] + public List? Data { get; set; } + } +} diff --git a/OTSSignsOrchestrator.Core/Services/IBitwardenSecretService.cs b/OTSSignsOrchestrator.Core/Services/IBitwardenSecretService.cs new file mode 100644 index 0000000..62652d1 --- /dev/null +++ b/OTSSignsOrchestrator.Core/Services/IBitwardenSecretService.cs @@ -0,0 +1,49 @@ +namespace OTSSignsOrchestrator.Core.Services; + +/// +/// Abstraction for storing and retrieving secrets via Bitwarden Secrets Manager. +/// +public interface IBitwardenSecretService +{ + /// + /// Returns true if Bitwarden is configured (access token + org ID are set). + /// + Task IsConfiguredAsync(); + + /// + /// Creates a new secret in the configured Bitwarden project. + /// + /// The ID of the created secret. + Task CreateSecretAsync(string key, string value, string note = ""); + + /// + /// Retrieves a secret by its Bitwarden ID. + /// + Task GetSecretAsync(string secretId); + + /// + /// Updates the value of an existing secret in place. + /// + Task UpdateSecretAsync(string secretId, string key, string value, string note = ""); + + /// + /// Lists all secrets in the configured project. + /// + Task> ListSecretsAsync(); +} + +public class BitwardenSecret +{ + public string Id { get; set; } = string.Empty; + public string Key { get; set; } = string.Empty; + public string Value { get; set; } = string.Empty; + public string Note { get; set; } = string.Empty; + public DateTime CreationDate { get; set; } +} + +public class BitwardenSecretSummary +{ + public string Id { get; set; } = string.Empty; + public string Key { get; set; } = string.Empty; + public DateTime CreationDate { get; set; } +} diff --git a/OTSSignsOrchestrator.Core/Services/InstanceService.cs b/OTSSignsOrchestrator.Core/Services/InstanceService.cs index e8b436f..7750540 100644 --- a/OTSSignsOrchestrator.Core/Services/InstanceService.cs +++ b/OTSSignsOrchestrator.Core/Services/InstanceService.cs @@ -32,6 +32,7 @@ public class InstanceService private readonly IDockerSecretsService _secrets; private readonly XiboApiService _xibo; private readonly SettingsService _settings; + private readonly PostInstanceInitService _postInit; private readonly DockerOptions _dockerOptions; private readonly ILogger _logger; @@ -44,6 +45,7 @@ public class InstanceService IDockerSecretsService secrets, XiboApiService xibo, SettingsService settings, + PostInstanceInitService postInit, IOptions dockerOptions, ILogger logger) { @@ -55,6 +57,7 @@ public class InstanceService _secrets = secrets; _xibo = xibo; _settings = settings; + _postInit = postInit; _dockerOptions = dockerOptions.Value; _logger = logger; } @@ -255,8 +258,13 @@ public class InstanceService _logger.LogInformation("Instance created: {StackName} | duration={DurationMs}ms", stackName, sw.ElapsedMilliseconds); + // ── 8. Post-instance init (fire-and-forget background task) ────── + // Waits for Xibo to be ready then creates admin user, OAuth app, and sets theme. + var instanceUrl = $"https://{cmsServerName}"; + _ = Task.Run(async () => await _postInit.RunAsync(abbrev, instanceUrl)); + deployResult.ServiceCount = 4; - deployResult.Message = "Instance deployed successfully."; + deployResult.Message = "Instance deployed successfully. Post-install setup is running in background."; return deployResult; } catch (Exception ex) diff --git a/OTSSignsOrchestrator.Core/Services/PostInstanceInitService.cs b/OTSSignsOrchestrator.Core/Services/PostInstanceInitService.cs new file mode 100644 index 0000000..ca596c7 --- /dev/null +++ b/OTSSignsOrchestrator.Core/Services/PostInstanceInitService.cs @@ -0,0 +1,345 @@ +using System.Security.Cryptography; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using OTSSignsOrchestrator.Core.Data; + +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. +/// 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. +/// +/// +/// Invoked as a background fire-and-forget task from . +/// 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. + /// Intended to be called as a background task after stack deployment succeeds. + /// + 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(); + var bws = scope.ServiceProvider.GetRequiredService(); + var settings = scope.ServiceProvider.GetRequiredService(); + var db = scope.ServiceProvider.GetRequiredService(); + + // ── 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) + // ───────────────────────────────────────────────────────────────────────── + + /// + /// 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 db = scope.ServiceProvider.GetRequiredService(); + + // 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; + } + + /// + /// Returns the stored admin password for an instance (from Bitwarden or local AppSettings). + /// + 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; + + 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 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("grant_type", "client_credentials"), + new KeyValuePair("client_id", clientId), + new KeyValuePair("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."); + } +} + +/// 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); +} diff --git a/OTSSignsOrchestrator.Core/Services/SettingsService.cs b/OTSSignsOrchestrator.Core/Services/SettingsService.cs index a55a8a8..8103db2 100644 --- a/OTSSignsOrchestrator.Core/Services/SettingsService.cs +++ b/OTSSignsOrchestrator.Core/Services/SettingsService.cs @@ -68,12 +68,31 @@ public class SettingsService public const string DefaultPhpUploadMaxFilesize = "Defaults.PhpUploadMaxFilesize"; public const string DefaultPhpMaxExecutionTime = "Defaults.PhpMaxExecutionTime"; + // Bitwarden Secrets Manager + public const string CatBitwarden = "Bitwarden"; + public const string BitwardenIdentityUrl = "Bitwarden.IdentityUrl"; + public const string BitwardenApiUrl = "Bitwarden.ApiUrl"; + public const string BitwardenAccessToken = "Bitwarden.AccessToken"; + public const string BitwardenOrganizationId = "Bitwarden.OrganizationId"; + public const string BitwardenProjectId = "Bitwarden.ProjectId"; + + // Xibo bootstrap OAuth2 credentials (one-time global setup for orchestrator API access) + public const string CatXibo = "Xibo"; + public const string XiboBootstrapClientId = "Xibo.BootstrapClientId"; + public const string XiboBootstrapClientSecret = "Xibo.BootstrapClientSecret"; + // Instance-specific (keyed by abbreviation) /// /// Builds a per-instance settings key for the MySQL password. /// Stored encrypted via DataProtection so it can be retrieved on update/redeploy. /// - public static string InstanceMySqlPassword(string abbrev) => $"Instance.{abbrev}.MySqlPassword"; + public static string InstanceMySqlPassword(string abbrev) => $"Instance.{abbrev}.MySqlPassword"; + /// Bitwarden secret ID for the instance's OTS admin password. + public static string InstanceAdminPasswordSecretId(string abbrev) => $"Instance.{abbrev}.AdminPasswordBwsId"; + /// Bitwarden secret ID for the instance's Xibo OAuth2 client secret. + public static string InstanceOAuthSecretId(string abbrev) => $"Instance.{abbrev}.OAuthSecretBwsId"; + /// Xibo OAuth2 client_id generated for this instance's OTS application. + public static string InstanceOAuthClientId(string abbrev) => $"Instance.{abbrev}.OAuthClientId"; public const string CatInstance = "Instance"; public SettingsService( diff --git a/OTSSignsOrchestrator.Core/Services/XiboApiService.cs b/OTSSignsOrchestrator.Core/Services/XiboApiService.cs index da7e70f..569c131 100644 --- a/OTSSignsOrchestrator.Core/Services/XiboApiService.cs +++ b/OTSSignsOrchestrator.Core/Services/XiboApiService.cs @@ -1,3 +1,7 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using OTSSignsOrchestrator.Core.Configuration; @@ -5,7 +9,13 @@ using OTSSignsOrchestrator.Core.Configuration; namespace OTSSignsOrchestrator.Core.Services; /// -/// Tests connectivity to deployed Xibo CMS instances using OAuth2. +/// Provides connectivity testing and administrative operations against deployed Xibo CMS instances. +/// +/// Bootstrap flow: +/// 1. A Xibo OAuth2 application with client_credentials grant must be created once +/// (stored in Settings → Xibo.BootstrapClientId / Xibo.BootstrapClientSecret). +/// 2. After a new instance is deployed, PostInstanceInitService calls into this service +/// to create the OTS admin user, register a dedicated OAuth2 app, and set the theme. /// public class XiboApiService { @@ -23,7 +33,11 @@ public class XiboApiService _logger = logger; } - public async Task TestConnectionAsync(string instanceUrl, string username, string password) + // ───────────────────────────────────────────────────────────────────────── + // Connection test + // ───────────────────────────────────────────────────────────────────────── + + public async Task TestConnectionAsync(string instanceUrl, string clientId, string clientSecret) { _logger.LogInformation("Testing Xibo connection to {InstanceUrl}", instanceUrl); @@ -32,43 +46,22 @@ public class XiboApiService try { - var baseUrl = instanceUrl.TrimEnd('/'); - var tokenUrl = $"{baseUrl}/api/authorize/access_token"; - - var formContent = new FormUrlEncodedContent(new[] - { - new KeyValuePair("grant_type", "client_credentials"), - new KeyValuePair("client_id", username), - new KeyValuePair("client_secret", password) - }); - - var response = await client.PostAsync(tokenUrl, formContent); - - if (response.IsSuccessStatusCode) - { - _logger.LogInformation("Xibo connection test succeeded for {InstanceUrl}", instanceUrl); - return new XiboTestResult - { - IsValid = true, - Message = "Connected successfully.", - HttpStatus = (int)response.StatusCode - }; - } - - _logger.LogWarning("Xibo connection test failed: {InstanceUrl} | status={StatusCode}", - instanceUrl, (int)response.StatusCode); - + var token = await GetTokenAsync(instanceUrl, clientId, clientSecret, client); + _logger.LogInformation("Xibo connection test succeeded for {InstanceUrl}", instanceUrl); return new XiboTestResult { - IsValid = false, - Message = response.StatusCode switch - { - System.Net.HttpStatusCode.Unauthorized => "Invalid Xibo credentials.", - System.Net.HttpStatusCode.Forbidden => "User lacks API permissions.", - System.Net.HttpStatusCode.ServiceUnavailable => "Xibo instance not ready.", - _ => $"Unexpected response: {(int)response.StatusCode}" - }, - HttpStatus = (int)response.StatusCode + IsValid = true, + Message = "Connected successfully.", + HttpStatus = 200 + }; + } + catch (XiboAuthException ex) + { + return new XiboTestResult + { + IsValid = false, + Message = ex.Message, + HttpStatus = ex.HttpStatus }; } catch (TaskCanceledException) @@ -80,11 +73,261 @@ public class XiboApiService return new XiboTestResult { IsValid = false, Message = $"Cannot reach Xibo instance: {ex.Message}" }; } } + + // ───────────────────────────────────────────────────────────────────────── + // Health / readiness + // ───────────────────────────────────────────────────────────────────────── + + /// + /// Polls until Xibo returns a 200 from its + /// /about endpoint or elapses. + /// + public async Task WaitForReadyAsync( + string instanceUrl, + TimeSpan timeout, + CancellationToken ct = default) + { + var deadline = DateTime.UtcNow + timeout; + var baseUrl = instanceUrl.TrimEnd('/'); + var client = _httpClientFactory.CreateClient("XiboHealth"); + client.Timeout = TimeSpan.FromSeconds(10); + + _logger.LogInformation("Waiting for Xibo instance to become ready: {Url}", baseUrl); + + while (DateTime.UtcNow < deadline && !ct.IsCancellationRequested) + { + try + { + var response = await client.GetAsync($"{baseUrl}/api/about", ct); + if (response.IsSuccessStatusCode) + { + _logger.LogInformation("Xibo is ready: {Url}", baseUrl); + return true; + } + } + catch { /* not yet available */ } + + await Task.Delay(TimeSpan.FromSeconds(10), ct); + } + + _logger.LogWarning("Xibo did not become ready within {Timeout}: {Url}", timeout, baseUrl); + return false; + } + + // ───────────────────────────────────────────────────────────────────────── + // Admin user + // ───────────────────────────────────────────────────────────────────────── + + /// + /// Creates a new super-admin user in the Xibo instance and returns its numeric ID. + /// + public async Task CreateAdminUserAsync( + string instanceUrl, + string bootstrapClientId, + string bootstrapClientSecret, + string newUsername, + string newPassword, + string email) + { + var client = _httpClientFactory.CreateClient("XiboApi"); + var baseUrl = instanceUrl.TrimEnd('/'); + + var token = await GetTokenAsync(baseUrl, bootstrapClientId, bootstrapClientSecret, client); + SetBearer(client, token); + + var form = new FormUrlEncodedContent(new[] + { + new KeyValuePair("userName", newUsername), + new KeyValuePair("email", email), + new KeyValuePair("userTypeId", "1"), // Super Admin + new KeyValuePair("homePageId", "1"), + new KeyValuePair("libraryQuota", "0"), + new KeyValuePair("groupId", "1"), + new KeyValuePair("newUserPassword", newPassword), + new KeyValuePair("retypeNewUserPassword", newPassword), + new KeyValuePair("isPasswordChangeRequired", "0"), + }); + + var response = await client.PostAsync($"{baseUrl}/api/user", form); + await EnsureSuccessAsync(response, "create Xibo admin user"); + + using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync()); + var userId = doc.RootElement.GetProperty("userId").GetInt32(); + + _logger.LogInformation("Xibo admin user created: username={Username}, userId={UserId}", newUsername, userId); + return userId; + } + + /// + /// Changes the password of an existing Xibo user. + /// + public async Task RotateUserPasswordAsync( + string instanceUrl, + string bootstrapClientId, + string bootstrapClientSecret, + int userId, + string newPassword) + { + var client = _httpClientFactory.CreateClient("XiboApi"); + var baseUrl = instanceUrl.TrimEnd('/'); + + var token = await GetTokenAsync(baseUrl, bootstrapClientId, bootstrapClientSecret, client); + SetBearer(client, token); + + var form = new FormUrlEncodedContent(new[] + { + new KeyValuePair("newUserPassword", newPassword), + new KeyValuePair("retypeNewUserPassword", newPassword), + }); + + var response = await client.PutAsync($"{baseUrl}/api/user/{userId}", form); + await EnsureSuccessAsync(response, "rotate Xibo user password"); + + _logger.LogInformation("Xibo user password rotated: userId={UserId}", userId); + } + + // ───────────────────────────────────────────────────────────────────────── + // OAuth2 application + // ───────────────────────────────────────────────────────────────────────── + + /// + /// Registers a new client_credentials OAuth2 application in Xibo and returns + /// the generated client_id and client_secret. + /// + public async Task<(string ClientId, string ClientSecret)> RegisterOAuthClientAsync( + string instanceUrl, + string bootstrapClientId, + string bootstrapClientSecret, + string appName) + { + var client = _httpClientFactory.CreateClient("XiboApi"); + var baseUrl = instanceUrl.TrimEnd('/'); + + var token = await GetTokenAsync(baseUrl, bootstrapClientId, bootstrapClientSecret, client); + SetBearer(client, token); + + var form = new FormUrlEncodedContent(new[] + { + new KeyValuePair("name", appName), + new KeyValuePair("clientId", Guid.NewGuid().ToString("N")), + new KeyValuePair("confidential", "1"), + new KeyValuePair("authCode", "0"), + new KeyValuePair("clientCredentials", "1"), + }); + + var response = await client.PostAsync($"{baseUrl}/api/application", form); + await EnsureSuccessAsync(response, "register Xibo OAuth2 application"); + + using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync()); + var root = doc.RootElement; + var cid = root.GetProperty("key").GetString() + ?? throw new InvalidOperationException("Xibo application 'key' missing in response."); + var secret = root.GetProperty("secret").GetString() + ?? throw new InvalidOperationException("Xibo application 'secret' missing in response."); + + _logger.LogInformation("Xibo OAuth2 application registered: name={Name}, clientId={ClientId}", appName, cid); + return (cid, secret); + } + + // ───────────────────────────────────────────────────────────────────────── + // Theme + // ───────────────────────────────────────────────────────────────────────── + + /// + /// Sets the active CMS theme by writing the THEME_FOLDER setting. + /// + public async Task SetThemeAsync( + string instanceUrl, + string bootstrapClientId, + string bootstrapClientSecret, + string themeFolderName = "otssigns") + { + var client = _httpClientFactory.CreateClient("XiboApi"); + var baseUrl = instanceUrl.TrimEnd('/'); + + var token = await GetTokenAsync(baseUrl, bootstrapClientId, bootstrapClientSecret, client); + SetBearer(client, token); + + // Xibo stores settings as an array: settings[THEME_FOLDER]=otssigns + var form = new FormUrlEncodedContent(new[] + { + new KeyValuePair("settings[THEME_FOLDER]", themeFolderName), + }); + + var response = await client.PostAsync($"{baseUrl}/api/admin/setting", form); + await EnsureSuccessAsync(response, "set Xibo theme"); + + _logger.LogInformation("Xibo theme set to: {Theme}", themeFolderName); + } + + // ───────────────────────────────────────────────────────────────────────── + // Helpers + // ───────────────────────────────────────────────────────────────────────── + + private async Task GetTokenAsync( + string baseUrl, + string clientId, + string clientSecret, + HttpClient client) + { + var tokenUrl = $"{baseUrl}/api/authorize/access_token"; + var form = new FormUrlEncodedContent(new[] + { + new KeyValuePair("grant_type", "client_credentials"), + new KeyValuePair("client_id", clientId), + new KeyValuePair("client_secret", clientSecret), + }); + + var response = await client.PostAsync(tokenUrl, form); + + if (!response.IsSuccessStatusCode) + { + throw new XiboAuthException( + response.StatusCode switch + { + System.Net.HttpStatusCode.Unauthorized => "Invalid Xibo credentials.", + System.Net.HttpStatusCode.Forbidden => "User lacks API permissions.", + System.Net.HttpStatusCode.ServiceUnavailable => "Xibo instance not ready.", + _ => $"Unexpected response: {(int)response.StatusCode}" + }, + (int)response.StatusCode); + } + + using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync()); + var aToken = doc.RootElement.GetProperty("access_token").GetString() + ?? throw new InvalidOperationException("access_token missing in Xibo token response."); + return aToken; + } + + private static void SetBearer(HttpClient client, string token) + => client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + + private static async Task EnsureSuccessAsync(HttpResponseMessage response, string operation) + { + if (!response.IsSuccessStatusCode) + { + var body = await response.Content.ReadAsStringAsync(); + throw new InvalidOperationException( + $"Xibo API call '{operation}' failed: {(int)response.StatusCode} — {body}"); + } + } } +// ───────────────────────────────────────────────────────────────────────────── +// Result / exception types +// ───────────────────────────────────────────────────────────────────────────── + public class XiboTestResult { - public bool IsValid { get; set; } - public string Message { get; set; } = string.Empty; - public int HttpStatus { get; set; } + public bool IsValid { get; set; } + public string Message { get; set; } = string.Empty; + public int HttpStatus { get; set; } } + +public class XiboAuthException : Exception +{ + public int HttpStatus { get; } + public XiboAuthException(string message, int httpStatus) : base(message) + => HttpStatus = httpStatus; +} + diff --git a/OTSSignsOrchestrator.Desktop/App.axaml.cs b/OTSSignsOrchestrator.Desktop/App.axaml.cs index a9565ba..7f848a7 100644 --- a/OTSSignsOrchestrator.Desktop/App.axaml.cs +++ b/OTSSignsOrchestrator.Desktop/App.axaml.cs @@ -114,6 +114,8 @@ public class App : Application // HTTP services.AddHttpClient(); services.AddHttpClient("XiboApi"); + services.AddHttpClient("XiboHealth"); + services.AddHttpClient("Bitwarden"); // SSH services (singletons — maintain connections) services.AddSingleton(); @@ -131,11 +133,14 @@ public class App : Application services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); + services.AddSingleton(); // ViewModels services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); diff --git a/OTSSignsOrchestrator.Desktop/ViewModels/InstanceDetailsViewModel.cs b/OTSSignsOrchestrator.Desktop/ViewModels/InstanceDetailsViewModel.cs new file mode 100644 index 0000000..e8e3c8c --- /dev/null +++ b/OTSSignsOrchestrator.Desktop/ViewModels/InstanceDetailsViewModel.cs @@ -0,0 +1,266 @@ +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Microsoft.Extensions.DependencyInjection; +using OTSSignsOrchestrator.Core.Models.Entities; +using OTSSignsOrchestrator.Core.Services; +using OTSSignsOrchestrator.Desktop.Models; +using OTSSignsOrchestrator.Desktop.Services; + +namespace OTSSignsOrchestrator.Desktop.ViewModels; + +/// +/// ViewModel for the instance details modal. +/// Shows admin credentials, DB credentials, and OAuth2 app details +/// with options to rotate passwords. +/// +public partial class InstanceDetailsViewModel : ObservableObject +{ + private readonly IServiceProvider _services; + + // ── Instance metadata ───────────────────────────────────────────────────── + [ObservableProperty] private string _stackName = string.Empty; + [ObservableProperty] private string _customerAbbrev = string.Empty; + [ObservableProperty] private string _hostLabel = string.Empty; + [ObservableProperty] private string _instanceUrl = string.Empty; + + // ── OTS admin credentials ───────────────────────────────────────────────── + [ObservableProperty] private string _adminUsername = string.Empty; + [ObservableProperty] private string _adminPassword = string.Empty; + [ObservableProperty] private bool _adminPasswordVisible = false; + [ObservableProperty] private string _adminPasswordDisplay = "••••••••"; + + // ── Database credentials ────────────────────────────────────────────────── + [ObservableProperty] private string _dbUsername = string.Empty; + [ObservableProperty] private string _dbPassword = string.Empty; + [ObservableProperty] private bool _dbPasswordVisible = false; + [ObservableProperty] private string _dbPasswordDisplay = "••••••••"; + + // ── OAuth2 application ──────────────────────────────────────────────────── + [ObservableProperty] private string _oAuthClientId = string.Empty; + [ObservableProperty] private string _oAuthClientSecret = string.Empty; + [ObservableProperty] private bool _oAuthSecretVisible = false; + [ObservableProperty] private string _oAuthSecretDisplay = "••••••••"; + + // ── Status ──────────────────────────────────────────────────────────────── + [ObservableProperty] private string _statusMessage = string.Empty; + [ObservableProperty] private bool _isBusy; + + public InstanceDetailsViewModel(IServiceProvider services) + { + _services = services; + } + + // ───────────────────────────────────────────────────────────────────────── + // Load + // ───────────────────────────────────────────────────────────────────────── + + /// Populates the ViewModel from a live . + public async Task LoadAsync(LiveStackItem instance) + { + StackName = instance.StackName; + CustomerAbbrev = instance.CustomerAbbrev; + HostLabel = instance.HostLabel; + + IsBusy = true; + StatusMessage = "Loading credentials..."; + + try + { + using var scope = _services.CreateScope(); + var settings = scope.ServiceProvider.GetRequiredService(); + var postInit = scope.ServiceProvider.GetRequiredService(); + + // Derive the instance URL from the CMS server name template + var serverTemplate = await settings.GetAsync( + SettingsService.DefaultCmsServerNameTemplate, "{abbrev}.ots-signs.com"); + var serverName = serverTemplate.Replace("{abbrev}", instance.CustomerAbbrev); + InstanceUrl = $"https://{serverName}"; + + // ── Admin credentials ───────────────────────────────────────── + var creds = await postInit.GetCredentialsAsync(instance.CustomerAbbrev); + AdminUsername = creds.AdminUsername; + SetAdminPassword(creds.AdminPassword ?? string.Empty); + + OAuthClientId = creds.OAuthClientId ?? string.Empty; + SetOAuthSecret(creds.OAuthClientSecret ?? string.Empty); + + // ── DB credentials ──────────────────────────────────────────── + var mySqlUserTemplate = await settings.GetAsync( + SettingsService.DefaultMySqlUserTemplate, "{abbrev}_cms_user"); + DbUsername = mySqlUserTemplate.Replace("{abbrev}", instance.CustomerAbbrev); + + var dbPw = await settings.GetAsync(SettingsService.InstanceMySqlPassword(instance.CustomerAbbrev)); + SetDbPassword(dbPw ?? string.Empty); + + StatusMessage = creds.HasAdminPassword + ? "Credentials loaded." + : "Credentials not yet available — post-install setup may still be running."; + } + catch (Exception ex) + { + StatusMessage = $"Error loading credentials: {ex.Message}"; + } + finally + { + IsBusy = false; + } + } + + // ───────────────────────────────────────────────────────────────────────── + // Visibility toggles + // ───────────────────────────────────────────────────────────────────────── + + [RelayCommand] + private void ToggleAdminPasswordVisibility() + { + AdminPasswordVisible = !AdminPasswordVisible; + AdminPasswordDisplay = AdminPasswordVisible + ? AdminPassword + : (AdminPassword.Length > 0 ? "••••••••" : "(not set)"); + } + + [RelayCommand] + private void ToggleDbPasswordVisibility() + { + DbPasswordVisible = !DbPasswordVisible; + DbPasswordDisplay = DbPasswordVisible + ? DbPassword + : (DbPassword.Length > 0 ? "••••••••" : "(not set)"); + } + + [RelayCommand] + private void ToggleOAuthSecretVisibility() + { + OAuthSecretVisible = !OAuthSecretVisible; + OAuthSecretDisplay = OAuthSecretVisible + ? OAuthClientSecret + : (OAuthClientSecret.Length > 0 ? "••••••••" : "(not set)"); + } + + // ───────────────────────────────────────────────────────────────────────── + // Clipboard + // ───────────────────────────────────────────────────────────────────────── + + [RelayCommand] + private async Task CopyAdminPasswordAsync() + => await CopyToClipboardAsync(AdminPassword, "Admin password"); + + [RelayCommand] + private async Task CopyDbPasswordAsync() + => await CopyToClipboardAsync(DbPassword, "DB password"); + + [RelayCommand] + private async Task CopyOAuthClientIdAsync() + => await CopyToClipboardAsync(OAuthClientId, "OAuth client ID"); + + [RelayCommand] + private async Task CopyOAuthSecretAsync() + => await CopyToClipboardAsync(OAuthClientSecret, "OAuth client secret"); + + // ───────────────────────────────────────────────────────────────────────── + // Rotation + // ───────────────────────────────────────────────────────────────────────── + + [RelayCommand] + private async Task RotateAdminPasswordAsync() + { + if (string.IsNullOrWhiteSpace(CustomerAbbrev)) return; + + IsBusy = true; + StatusMessage = "Rotating OTS admin password..."; + try + { + var postInit = _services.GetRequiredService(); + var newPassword = await postInit.RotateAdminPasswordAsync(CustomerAbbrev, InstanceUrl); + SetAdminPassword(newPassword); + StatusMessage = "Admin password rotated successfully."; + } + catch (Exception ex) + { + StatusMessage = $"Error rotating admin password: {ex.Message}"; + } + finally + { + IsBusy = false; + } + } + + [RelayCommand] + private async Task RotateDbPasswordAsync() + { + if (string.IsNullOrWhiteSpace(CustomerAbbrev)) return; + + IsBusy = true; + StatusMessage = $"Rotating MySQL password for {StackName}..."; + try + { + var dockerCli = _services.GetRequiredService(); + var dockerSecrets = _services.GetRequiredService(); + + // We need the Host — retrieve from the HostLabel lookup + using var scope = _services.CreateScope(); + var instanceSvc = scope.ServiceProvider.GetRequiredService(); + + // Get the host from the loaded stack — caller must have set the SSH host before + var (ok, msg) = await instanceSvc.RotateMySqlPasswordAsync(StackName); + if (ok) + { + // Reload DB password + var settings = scope.ServiceProvider.GetRequiredService(); + var dbPw = await settings.GetAsync(SettingsService.InstanceMySqlPassword(CustomerAbbrev)); + SetDbPassword(dbPw ?? string.Empty); + StatusMessage = $"DB password rotated: {msg}"; + } + else + { + StatusMessage = $"DB rotation failed: {msg}"; + } + } + catch (Exception ex) + { + StatusMessage = $"Error rotating DB password: {ex.Message}"; + } + finally + { + IsBusy = false; + } + } + + // ───────────────────────────────────────────────────────────────────────── + // Helpers + // ───────────────────────────────────────────────────────────────────────── + + private void SetAdminPassword(string value) + { + AdminPassword = value; + AdminPasswordVisible = false; + AdminPasswordDisplay = value.Length > 0 ? "••••••••" : "(not set)"; + } + + private void SetDbPassword(string value) + { + DbPassword = value; + DbPasswordVisible = false; + DbPasswordDisplay = value.Length > 0 ? "••••••••" : "(not set)"; + } + + private void SetOAuthSecret(string value) + { + OAuthClientSecret = value; + OAuthSecretVisible = false; + OAuthSecretDisplay = value.Length > 0 ? "••••••••" : "(not set)"; + } + + private static async Task CopyToClipboardAsync(string text, string label) + { + if (string.IsNullOrEmpty(text)) return; + var topLevel = Avalonia.Application.Current?.ApplicationLifetime is + Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime dt + ? dt.MainWindow + : null; + var clipboard = topLevel is not null ? Avalonia.Controls.TopLevel.GetTopLevel(topLevel)?.Clipboard : null; + if (clipboard is not null) + await clipboard.SetTextAsync(text); + } +} diff --git a/OTSSignsOrchestrator.Desktop/ViewModels/InstancesViewModel.cs b/OTSSignsOrchestrator.Desktop/ViewModels/InstancesViewModel.cs index 5f3ea41..850732f 100644 --- a/OTSSignsOrchestrator.Desktop/ViewModels/InstancesViewModel.cs +++ b/OTSSignsOrchestrator.Desktop/ViewModels/InstancesViewModel.cs @@ -30,6 +30,9 @@ public partial class InstancesViewModel : ObservableObject [ObservableProperty] private ObservableCollection _availableHosts = new(); [ObservableProperty] private SshHost? _selectedSshHost; + /// Raised when the instance details modal should be opened for the given ViewModel. + public event Action? OpenDetailsRequested; + public InstancesViewModel(IServiceProvider services) { _services = services; @@ -158,4 +161,29 @@ public partial class InstancesViewModel : ObservableObject catch (Exception ex) { StatusMessage = $"Error rotating password: {ex.Message}"; } finally { IsBusy = false; } } + + [RelayCommand] + private async Task OpenDetailsAsync() + { + if (SelectedInstance == null) return; + + IsBusy = true; + StatusMessage = $"Loading details for '{SelectedInstance.StackName}'..."; + try + { + // Set the SSH host on singleton Docker services so modal operations target the right host + var dockerCli = _services.GetRequiredService(); + dockerCli.SetHost(SelectedInstance.Host); + var dockerSecrets = _services.GetRequiredService(); + dockerSecrets.SetHost(SelectedInstance.Host); + + var detailsVm = _services.GetRequiredService(); + await detailsVm.LoadAsync(SelectedInstance); + + OpenDetailsRequested?.Invoke(detailsVm); + StatusMessage = string.Empty; + } + catch (Exception ex) { StatusMessage = $"Error opening details: {ex.Message}"; } + finally { IsBusy = false; } + } } diff --git a/OTSSignsOrchestrator.Desktop/ViewModels/SettingsViewModel.cs b/OTSSignsOrchestrator.Desktop/ViewModels/SettingsViewModel.cs index ec563be..ca277c6 100644 --- a/OTSSignsOrchestrator.Desktop/ViewModels/SettingsViewModel.cs +++ b/OTSSignsOrchestrator.Desktop/ViewModels/SettingsViewModel.cs @@ -59,6 +59,17 @@ public partial class SettingsViewModel : ObservableObject [ObservableProperty] private string _defaultPhpUploadMaxFilesize = "10G"; [ObservableProperty] private string _defaultPhpMaxExecutionTime = "600"; + // ── Bitwarden Secrets Manager ───────────────────────────────── + [ObservableProperty] private string _bitwardenIdentityUrl = "https://identity.bitwarden.com"; + [ObservableProperty] private string _bitwardenApiUrl = "https://api.bitwarden.com"; + [ObservableProperty] private string _bitwardenAccessToken = string.Empty; + [ObservableProperty] private string _bitwardenOrganizationId = string.Empty; + [ObservableProperty] private string _bitwardenProjectId = string.Empty; + + // ── Xibo Bootstrap OAuth2 ───────────────────────────────────── + [ObservableProperty] private string _xiboBootstrapClientId = string.Empty; + [ObservableProperty] private string _xiboBootstrapClientSecret = string.Empty; + public SettingsViewModel(IServiceProvider services) { _services = services; @@ -116,6 +127,17 @@ public partial class SettingsViewModel : ObservableObject DefaultPhpUploadMaxFilesize = await svc.GetAsync(SettingsService.DefaultPhpUploadMaxFilesize, "10G"); DefaultPhpMaxExecutionTime = await svc.GetAsync(SettingsService.DefaultPhpMaxExecutionTime, "600"); + // Bitwarden + BitwardenIdentityUrl = await svc.GetAsync(SettingsService.BitwardenIdentityUrl, "https://identity.bitwarden.com"); + BitwardenApiUrl = await svc.GetAsync(SettingsService.BitwardenApiUrl, "https://api.bitwarden.com"); + BitwardenAccessToken = await svc.GetAsync(SettingsService.BitwardenAccessToken, string.Empty); + BitwardenOrganizationId = await svc.GetAsync(SettingsService.BitwardenOrganizationId, string.Empty); + BitwardenProjectId = await svc.GetAsync(SettingsService.BitwardenProjectId, string.Empty); + + // Xibo Bootstrap + XiboBootstrapClientId = await svc.GetAsync(SettingsService.XiboBootstrapClientId, string.Empty); + XiboBootstrapClientSecret = await svc.GetAsync(SettingsService.XiboBootstrapClientSecret, string.Empty); + StatusMessage = "Settings loaded."; } catch (Exception ex) @@ -180,6 +202,17 @@ public partial class SettingsViewModel : ObservableObject (SettingsService.DefaultPhpPostMaxSize, DefaultPhpPostMaxSize, SettingsService.CatDefaults, false), (SettingsService.DefaultPhpUploadMaxFilesize, DefaultPhpUploadMaxFilesize, SettingsService.CatDefaults, false), (SettingsService.DefaultPhpMaxExecutionTime, DefaultPhpMaxExecutionTime, SettingsService.CatDefaults, false), + + // Bitwarden + (SettingsService.BitwardenIdentityUrl, NullIfEmpty(BitwardenIdentityUrl), SettingsService.CatBitwarden, false), + (SettingsService.BitwardenApiUrl, NullIfEmpty(BitwardenApiUrl), SettingsService.CatBitwarden, false), + (SettingsService.BitwardenAccessToken, NullIfEmpty(BitwardenAccessToken), SettingsService.CatBitwarden, true), + (SettingsService.BitwardenOrganizationId, NullIfEmpty(BitwardenOrganizationId), SettingsService.CatBitwarden, false), + (SettingsService.BitwardenProjectId, NullIfEmpty(BitwardenProjectId), SettingsService.CatBitwarden, false), + + // Xibo Bootstrap + (SettingsService.XiboBootstrapClientId, NullIfEmpty(XiboBootstrapClientId), SettingsService.CatXibo, false), + (SettingsService.XiboBootstrapClientSecret, NullIfEmpty(XiboBootstrapClientSecret), SettingsService.CatXibo, true), }; await svc.SaveManyAsync(settings); @@ -195,6 +228,64 @@ public partial class SettingsViewModel : ObservableObject } } + [RelayCommand] + private async Task TestBitwardenConnectionAsync() + { + if (string.IsNullOrWhiteSpace(BitwardenAccessToken) || string.IsNullOrWhiteSpace(BitwardenOrganizationId)) + { + StatusMessage = "Bitwarden Access Token and Organization ID are required."; + return; + } + + IsBusy = true; + StatusMessage = "Testing Bitwarden Secrets Manager connection..."; + try + { + using var scope = _services.CreateScope(); + var bws = scope.ServiceProvider.GetRequiredService(); + var secrets = await bws.ListSecretsAsync(); + StatusMessage = $"Bitwarden connected. Found {secrets.Count} secret(s) in project."; + } + catch (Exception ex) + { + StatusMessage = $"Bitwarden connection failed: {ex.Message}"; + } + finally + { + IsBusy = false; + } + } + + [RelayCommand] + private async Task TestXiboBootstrapAsync() + { + if (string.IsNullOrWhiteSpace(XiboBootstrapClientId) || string.IsNullOrWhiteSpace(XiboBootstrapClientSecret)) + { + StatusMessage = "Xibo Bootstrap Client ID and Secret are required."; + return; + } + + IsBusy = true; + StatusMessage = "Testing Xibo bootstrap credentials..."; + try + { + using var scope = _services.CreateScope(); + var xibo = scope.ServiceProvider.GetRequiredService(); + var svc = scope.ServiceProvider.GetRequiredService(); + var cmsServerTemplate = await svc.GetAsync(SettingsService.DefaultCmsServerNameTemplate, "{abbrev}.ots-signs.com"); + // Use a placeholder URL — user must configure a live instance for full test + StatusMessage = "Xibo bootstrap credentials saved. Connect test requires a live instance URL — use 'Details' on an instance to verify."; + } + catch (Exception ex) + { + StatusMessage = $"Error: {ex.Message}"; + } + finally + { + IsBusy = false; + } + } + [RelayCommand] private async Task TestMySqlConnectionAsync() { diff --git a/OTSSignsOrchestrator.Desktop/Views/InstanceDetailsWindow.axaml b/OTSSignsOrchestrator.Desktop/Views/InstanceDetailsWindow.axaml new file mode 100644 index 0000000..d06ff76 --- /dev/null +++ b/OTSSignsOrchestrator.Desktop/Views/InstanceDetailsWindow.axaml @@ -0,0 +1,146 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +