diff --git a/.template-cache/053604496cfa3867 b/.template-cache/053604496cfa3867 new file mode 160000 index 0000000..a6ab3c2 --- /dev/null +++ b/.template-cache/053604496cfa3867 @@ -0,0 +1 @@ +Subproject commit a6ab3c254bee92183ac6b3af9405658b0a02e1d2 diff --git a/.template-cache/2dc03e2b2b45fef3 b/.template-cache/2dc03e2b2b45fef3 index a6ab3c2..07ab87b 160000 --- a/.template-cache/2dc03e2b2b45fef3 +++ b/.template-cache/2dc03e2b2b45fef3 @@ -1 +1 @@ -Subproject commit a6ab3c254bee92183ac6b3af9405658b0a02e1d2 +Subproject commit 07ab87bc65fa52879cadb8a18de60a7c51ac6d78 diff --git a/OTSSignsOrchestrator.Core/Configuration/AppOptions.cs b/OTSSignsOrchestrator.Core/Configuration/AppOptions.cs index e3cb2d4..dd7bb4e 100644 --- a/OTSSignsOrchestrator.Core/Configuration/AppOptions.cs +++ b/OTSSignsOrchestrator.Core/Configuration/AppOptions.cs @@ -47,6 +47,32 @@ public class DatabaseOptions public string Provider { get; set; } = "Sqlite"; } +/// +/// Bitwarden Secrets Manager connection settings. +/// Stored in appsettings.json so they can bootstrap the connection before any other settings are loaded. +/// +public class BitwardenOptions +{ + public const string SectionName = "Bitwarden"; + + public string IdentityUrl { get; set; } = "https://identity.bitwarden.com"; + public string ApiUrl { get; set; } = "https://api.bitwarden.com"; + + /// Machine account access token (sensitive — may be set via environment variable). + public string AccessToken { get; set; } = string.Empty; + + public string OrganizationId { get; set; } = string.Empty; + + /// Project where config secrets are created/listed. Required. + public string ProjectId { get; set; } = string.Empty; + + /// + /// Optional separate project for instance-level secrets (DB passwords, Newt credentials, etc.). + /// When empty, instance secrets are stored in the default . + /// + public string InstanceProjectId { get; set; } = string.Empty; +} + public class InstanceDefaultsOptions { public const string SectionName = "InstanceDefaults"; diff --git a/OTSSignsOrchestrator.Core/Data/XiboContext.cs b/OTSSignsOrchestrator.Core/Data/XiboContext.cs index f11d3f1..15ea03d 100644 --- a/OTSSignsOrchestrator.Core/Data/XiboContext.cs +++ b/OTSSignsOrchestrator.Core/Data/XiboContext.cs @@ -17,7 +17,6 @@ public class XiboContext : DbContext public DbSet SshHosts => Set(); public DbSet OperationLogs => Set(); - public DbSet AppSettings => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -50,12 +49,5 @@ public class XiboContext : DbContext entity.HasIndex(e => e.StackName); entity.HasIndex(e => e.Operation); }); - - // --- AppSetting --- - modelBuilder.Entity(entity => - { - entity.HasKey(e => e.Key); - entity.HasIndex(e => e.Category); - }); } } diff --git a/OTSSignsOrchestrator.Core/Migrations/20260225135644_DropAppSettings.Designer.cs b/OTSSignsOrchestrator.Core/Migrations/20260225135644_DropAppSettings.Designer.cs new file mode 100644 index 0000000..392ea49 --- /dev/null +++ b/OTSSignsOrchestrator.Core/Migrations/20260225135644_DropAppSettings.Designer.cs @@ -0,0 +1,125 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using OTSSignsOrchestrator.Core.Data; + +#nullable disable + +namespace OTSSignsOrchestrator.Core.Migrations +{ + [DbContext(typeof(XiboContext))] + [Migration("20260225135644_DropAppSettings")] + partial class DropAppSettings + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.2"); + + modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.OperationLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("DurationMs") + .HasColumnType("INTEGER"); + + b.Property("Message") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Operation") + .HasColumnType("INTEGER"); + + b.Property("StackName") + .HasMaxLength(150) + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Operation"); + + b.HasIndex("StackName"); + + b.HasIndex("Timestamp"); + + b.ToTable("OperationLogs"); + }); + + modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SshHost", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("KeyPassphrase") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("LastTestSuccess") + .HasColumnType("INTEGER"); + + b.Property("LastTestedAt") + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("PrivateKeyPath") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UseKeyAuth") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Label") + .IsUnique(); + + b.ToTable("SshHosts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/OTSSignsOrchestrator.Core/Migrations/20260225135644_DropAppSettings.cs b/OTSSignsOrchestrator.Core/Migrations/20260225135644_DropAppSettings.cs new file mode 100644 index 0000000..8048481 --- /dev/null +++ b/OTSSignsOrchestrator.Core/Migrations/20260225135644_DropAppSettings.cs @@ -0,0 +1,42 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace OTSSignsOrchestrator.Core.Migrations +{ + /// + public partial class DropAppSettings : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AppSettings"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AppSettings", + columns: table => new + { + Key = table.Column(type: "TEXT", maxLength: 200, nullable: false), + Category = table.Column(type: "TEXT", maxLength: 50, nullable: false), + IsSensitive = table.Column(type: "INTEGER", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false), + Value = table.Column(type: "TEXT", maxLength: 4000, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AppSettings", x => x.Key); + }); + + migrationBuilder.CreateIndex( + name: "IX_AppSettings_Category", + table: "AppSettings", + column: "Category"); + } + } +} diff --git a/OTSSignsOrchestrator.Core/Migrations/XiboContextModelSnapshot.cs b/OTSSignsOrchestrator.Core/Migrations/XiboContextModelSnapshot.cs index 437aa0e..011fcef 100644 --- a/OTSSignsOrchestrator.Core/Migrations/XiboContextModelSnapshot.cs +++ b/OTSSignsOrchestrator.Core/Migrations/XiboContextModelSnapshot.cs @@ -17,34 +17,6 @@ namespace OTSSignsOrchestrator.Core.Migrations #pragma warning disable 612, 618 modelBuilder.HasAnnotation("ProductVersion", "9.0.2"); - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.AppSetting", b => - { - b.Property("Key") - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.Property("Category") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("TEXT"); - - b.Property("IsSensitive") - .HasColumnType("INTEGER"); - - b.Property("UpdatedAt") - .HasColumnType("TEXT"); - - b.Property("Value") - .HasMaxLength(4000) - .HasColumnType("TEXT"); - - b.HasKey("Key"); - - b.HasIndex("Category"); - - b.ToTable("AppSettings"); - }); - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.OperationLog", b => { b.Property("Id") diff --git a/OTSSignsOrchestrator.Core/Models/DTOs/CreateInstanceDto.cs b/OTSSignsOrchestrator.Core/Models/DTOs/CreateInstanceDto.cs index b55e73e..96fb2b0 100644 --- a/OTSSignsOrchestrator.Core/Models/DTOs/CreateInstanceDto.cs +++ b/OTSSignsOrchestrator.Core/Models/DTOs/CreateInstanceDto.cs @@ -37,4 +37,11 @@ public class CreateInstanceDto [MaxLength(500)] public string? NfsExtraOptions { get; set; } + + /// + /// When true, any existing Docker volumes with the same stack prefix are removed before + /// deploying, so fresh volumes are created from the current compose driver_opts. + /// Defaults to false to avoid accidental data loss on re-deploys. + /// + public bool PurgeStaleVolumes { get; set; } = false; } diff --git a/OTSSignsOrchestrator.Core/Models/DTOs/DeploymentResultDto.cs b/OTSSignsOrchestrator.Core/Models/DTOs/DeploymentResultDto.cs index 85eda3a..a89573b 100644 --- a/OTSSignsOrchestrator.Core/Models/DTOs/DeploymentResultDto.cs +++ b/OTSSignsOrchestrator.Core/Models/DTOs/DeploymentResultDto.cs @@ -10,4 +10,10 @@ public class DeploymentResultDto public int ExitCode { get; set; } public long DurationMs { get; set; } public int ServiceCount { get; set; } + + /// The instance URL including the abbreviation sub-path (e.g. https://ots.ots-signs.com/ots). + public string? InstanceUrl { get; set; } + + /// The 3-letter abbreviation for this instance. + public string? Abbrev { get; set; } } diff --git a/OTSSignsOrchestrator.Core/Models/DTOs/ServiceLogEntry.cs b/OTSSignsOrchestrator.Core/Models/DTOs/ServiceLogEntry.cs new file mode 100644 index 0000000..1252c93 --- /dev/null +++ b/OTSSignsOrchestrator.Core/Models/DTOs/ServiceLogEntry.cs @@ -0,0 +1,24 @@ +namespace OTSSignsOrchestrator.Core.Models.DTOs; + +/// +/// Represents a single log line from a Docker service. +/// Parsed from docker service logs --timestamps output. +/// +public class ServiceLogEntry +{ + /// UTC timestamp of the log entry (from Docker). + public DateTimeOffset Timestamp { get; set; } + + /// Service/replica identifier (e.g. "acm-cms-stack_acm-web.1.abc123"). + public string Source { get; set; } = string.Empty; + + /// The log message text. + public string Message { get; set; } = string.Empty; + + /// Short service name without the stack prefix (e.g. "acm-web"). + public string ServiceName { get; set; } = string.Empty; + + /// Formatted display string for binding: "[timestamp] source | message". + public string DisplayLine => + $"[{Timestamp:HH:mm:ss}] {Source} | {Message}"; +} diff --git a/OTSSignsOrchestrator.Core/OTSSignsOrchestrator.Core.csproj b/OTSSignsOrchestrator.Core/OTSSignsOrchestrator.Core.csproj index e09659b..d9241c1 100644 --- a/OTSSignsOrchestrator.Core/OTSSignsOrchestrator.Core.csproj +++ b/OTSSignsOrchestrator.Core/OTSSignsOrchestrator.Core.csproj @@ -7,6 +7,7 @@ + diff --git a/OTSSignsOrchestrator.Core/Services/BitwardenSecretService.cs b/OTSSignsOrchestrator.Core/Services/BitwardenSecretService.cs index d179698..bdf9c73 100644 --- a/OTSSignsOrchestrator.Core/Services/BitwardenSecretService.cs +++ b/OTSSignsOrchestrator.Core/Services/BitwardenSecretService.cs @@ -1,255 +1,230 @@ -using System.Net.Http.Headers; -using System.Net.Http.Json; -using System.Text.Json; -using System.Text.Json.Serialization; +using Bitwarden.Sdk; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using OTSSignsOrchestrator.Core.Configuration; namespace OTSSignsOrchestrator.Core.Services; /// -/// Stores and retrieves secrets from Bitwarden Secrets Manager (machine account API). +/// Stores and retrieves secrets from Bitwarden Secrets Manager via the official Bitwarden C# SDK. /// -/// 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 +/// Configuration is read from (bound to appsettings.json → "Bitwarden"). +/// +/// The SDK state file is persisted to %APPDATA%/OTSSignsOrchestrator/bitwarden.state +/// so the SDK can cache its internal state across restarts. /// -public class BitwardenSecretService : IBitwardenSecretService +public class BitwardenSecretService : IBitwardenSecretService, IDisposable { - private readonly IHttpClientFactory _http; - private readonly SettingsService _settings; + private readonly IOptionsMonitor _optionsMonitor; private readonly ILogger _logger; - // Cached bearer token (refreshed per service lifetime — transient registration is assumed) - private string? _bearerToken; + // Lazily created on first use (per service instance — registered as Transient). + private BitwardenClient? _client; + private string? _clientAccessToken; // track which token the client was created with + private bool _disposed; + + /// Always returns the latest config snapshot (reloaded when appsettings.json changes). + private BitwardenOptions Options => _optionsMonitor.CurrentValue; public BitwardenSecretService( - IHttpClientFactory http, - SettingsService settings, + IOptionsMonitor optionsMonitor, ILogger logger) { - _http = http; - _settings = settings; - _logger = logger; + _optionsMonitor = optionsMonitor; + _logger = logger; } // ───────────────────────────────────────────────────────────────────────── // IBitwardenSecretService // ───────────────────────────────────────────────────────────────────────── - public async Task IsConfiguredAsync() + public Task IsConfiguredAsync() { - var token = await _settings.GetAsync(SettingsService.BitwardenAccessToken); - var orgId = await _settings.GetAsync(SettingsService.BitwardenOrganizationId); - return !string.IsNullOrWhiteSpace(token) && !string.IsNullOrWhiteSpace(orgId); + var opts = Options; + var configured = !string.IsNullOrWhiteSpace(opts.AccessToken) + && !string.IsNullOrWhiteSpace(opts.OrganizationId); + return Task.FromResult(configured); } 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 = await GetClientAsync(); + var orgId = GetOrgId(); + var projectIds = GetProjectIds(); - 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."); + var result = await Task.Run(() => client.Secrets.Create(orgId, key, value, note, projectIds)); _logger.LogInformation("Bitwarden secret created: key={Key}, id={Id}", key, result.Id); - return result.Id; + return result.Id.ToString(); + } + + public async Task CreateInstanceSecretAsync(string key, string value, string note = "") + { + var client = await GetClientAsync(); + var orgId = GetOrgId(); + var projectIds = GetInstanceProjectIds(); + + var result = await Task.Run(() => client.Secrets.Create(orgId, key, value, note, projectIds)); + + _logger.LogInformation("Bitwarden instance secret created: key={Key}, id={Id}, project={Project}", + key, result.Id, Options.InstanceProjectId); + return result.Id.ToString(); } 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."); + var client = await GetClientAsync(); + var result = await Task.Run(() => client.Secrets.Get(Guid.Parse(secretId))); return new BitwardenSecret { - Id = result.Id, + Id = result.Id.ToString(), Key = result.Key, Value = result.Value, Note = result.Note ?? string.Empty, - CreationDate = result.CreationDate + CreationDate = result.CreationDate.DateTime }; } 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 = await GetClientAsync(); + var orgId = GetOrgId(); + var projectIds = GetProjectIds(); - 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"); + await Task.Run(() => client.Secrets.Update(orgId, Guid.Parse(secretId), key, value, note, projectIds)); _logger.LogInformation("Bitwarden secret updated: key={Key}, id={Id}", key, secretId); } + public async Task UpdateInstanceSecretAsync(string secretId, string key, string value, string note = "") + { + var client = await GetClientAsync(); + var orgId = GetOrgId(); + var projectIds = GetInstanceProjectIds(); + + await Task.Run(() => client.Secrets.Update(orgId, Guid.Parse(secretId), key, value, note, projectIds)); + + _logger.LogInformation("Bitwarden instance secret updated: key={Key}, id={Id}, project={Project}", + key, secretId, Options.InstanceProjectId); + } + 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 client = await GetClientAsync(); + var orgId = GetOrgId(); + var result = await Task.Run(() => client.Secrets.List(orgId)); - 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 + return result.Data?.Select(s => new BitwardenSecretSummary { - Id = s.Id, + Id = s.Id.ToString(), Key = s.Key, - CreationDate = s.CreationDate + CreationDate = DateTime.MinValue }).ToList() ?? new List(); } // ───────────────────────────────────────────────────────────────────────── - // Auth + // SDK client initialisation // ───────────────────────────────────────────────────────────────────────── /// - /// 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}". + /// Returns an authenticated , creating and logging in on first use. /// - private async Task GetBearerTokenAsync() + private async Task GetClientAsync() { - if (_bearerToken is not null) - return _bearerToken; + var opts = Options; - 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[] + // If credentials changed since the client was created, tear it down so we re-auth + if (_client is not null && _clientAccessToken != opts.AccessToken) { - new KeyValuePair("grant_type", "client_credentials"), - new KeyValuePair("client_id", $"machine.{tokenId}"), - new KeyValuePair("client_secret", clientSecret), - new KeyValuePair("scope", "api.secrets"), - }); + _logger.LogInformation("Bitwarden credentials changed — recreating SDK client"); + _client.Dispose(); + _client = null; + } - var response = await client.PostAsync($"{identityUrl}/connect/token", form); - await EnsureSuccessAsync(response, "authenticate with Bitwarden identity"); + if (_client is not null) + return _client; - var token = await response.Content.ReadFromJsonAsync() - ?? throw new InvalidOperationException("Empty token response from Bitwarden."); + if (string.IsNullOrWhiteSpace(opts.AccessToken)) + throw new InvalidOperationException("Bitwarden AccessToken is not configured. Set it in Settings → Bitwarden."); - _bearerToken = token.AccessToken; - _logger.LogInformation("Bitwarden bearer token acquired."); - return _bearerToken; + var accessToken = opts.AccessToken; + var apiUrl = (opts.ApiUrl ?? "https://api.bitwarden.com").TrimEnd('/'); + var identityUrl = (opts.IdentityUrl ?? "https://identity.bitwarden.com").TrimEnd('/'); + + var sdkSettings = new BitwardenSettings { ApiUrl = apiUrl, IdentityUrl = identityUrl }; + var client = new BitwardenClient(sdkSettings); + + await Task.Run(() => client.Auth.LoginAccessToken(accessToken, GetStateFilePath())); + + _logger.LogInformation("Bitwarden SDK client initialised and authenticated."); + _client = client; + _clientAccessToken = accessToken; + return _client; } // ───────────────────────────────────────────────────────────────────────── // 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) + private Guid GetOrgId() { - var client = new HttpClient(); - client.DefaultRequestHeaders.Authorization = - new AuthenticationHeaderValue("Bearer", bearerToken); - return client; + var orgId = Options.OrganizationId; + if (string.IsNullOrWhiteSpace(orgId)) + throw new InvalidOperationException("Bitwarden OrganizationId is not configured."); + return Guid.Parse(orgId); } - private static async Task EnsureSuccessAsync(HttpResponseMessage response, string operation) + private Guid[] GetProjectIds() { - if (!response.IsSuccessStatusCode) + var projectId = Options.ProjectId; + if (string.IsNullOrWhiteSpace(projectId)) + throw new InvalidOperationException( + "Bitwarden ProjectId is required. Set it in Settings → Bitwarden."); + return new[] { Guid.Parse(projectId) }; + } + + /// + /// Returns the project IDs array for instance-level secrets. + /// Uses when configured, + /// otherwise falls back to the default . + /// + private Guid[] GetInstanceProjectIds() + { + var instanceProjectId = Options.InstanceProjectId; + if (!string.IsNullOrWhiteSpace(instanceProjectId)) { - var body = await response.Content.ReadAsStringAsync(); - throw new HttpRequestException( - $"Bitwarden API call '{operation}' failed: {(int)response.StatusCode} {response.ReasonPhrase} — {body}"); + _logger.LogDebug("Using instance Bitwarden project: {ProjectId}", instanceProjectId); + return new[] { Guid.Parse(instanceProjectId) }; + } + + // Fall back to the default config project + return GetProjectIds(); + } + + /// + /// Returns the path where the SDK stores its state between sessions. + /// Stored under %APPDATA%/OTSSignsOrchestrator/bitwarden.state. + /// + private static string GetStateFilePath() + { + var dir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "OTSSignsOrchestrator"); + Directory.CreateDirectory(dir); + return Path.Combine(dir, "bitwarden.state"); + } + + // ───────────────────────────────────────────────────────────────────────── + // IDisposable + // ───────────────────────────────────────────────────────────────────────── + + public void Dispose() + { + if (!_disposed) + { + _client?.Dispose(); + _disposed = true; } } - - // ───────────────────────────────────────────────────────────────────────── - // 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/ComposeRenderService.cs b/OTSSignsOrchestrator.Core/Services/ComposeRenderService.cs index edcdf90..8549d13 100644 --- a/OTSSignsOrchestrator.Core/Services/ComposeRenderService.cs +++ b/OTSSignsOrchestrator.Core/Services/ComposeRenderService.cs @@ -111,7 +111,10 @@ public class ComposeRenderService var export = (ctx.NfsExport ?? string.Empty).Trim('/'); var folder = (ctx.NfsExportFolder ?? string.Empty).Trim('/'); var path = string.IsNullOrEmpty(folder) ? export : $"{export}/{folder}"; - return $":/{path}"; + // When path is empty the prefix must be ":" with no trailing slash — the template + // already supplies the leading "/" before {{ABBREV}}, so ":" + "/ots/..." = ":/ots/..." + // (correct). Returning ":/" would produce "://ots/..." which Docker rejects. + return string.IsNullOrEmpty(path) ? ":" : $":/{path}"; } /// @@ -195,6 +198,12 @@ public class ComposeRenderService - {{ABBREV}}-cms-ca-certs:/var/www/cms/ca-certs ports: - "{{HOST_HTTP_PORT}}:80" + healthcheck: + test: ["CMD-SHELL", "curl -fsS --max-time 5 http://web:80/about | grep -Eo 'v?[0-9]+(\\.[0-9]+)+' >/dev/null || exit 1"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s networks: {{ABBREV}}-net: aliases: @@ -236,6 +245,9 @@ public class ComposeRenderService PANGOLIN_ENDPOINT: {{PANGOLIN_ENDPOINT}} NEWT_ID: {{NEWT_ID}} NEWT_SECRET: {{NEWT_SECRET}} + depends_on: + {{ABBREV}}-web: + condition: service_healthy networks: {{ABBREV}}-net: {} deploy: diff --git a/OTSSignsOrchestrator.Core/Services/IBitwardenSecretService.cs b/OTSSignsOrchestrator.Core/Services/IBitwardenSecretService.cs index 62652d1..f8dfb50 100644 --- a/OTSSignsOrchestrator.Core/Services/IBitwardenSecretService.cs +++ b/OTSSignsOrchestrator.Core/Services/IBitwardenSecretService.cs @@ -16,6 +16,13 @@ public interface IBitwardenSecretService /// The ID of the created secret. Task CreateSecretAsync(string key, string value, string note = ""); + /// + /// Creates a new secret in the instance Bitwarden project (falls back to default project if not configured). + /// Use this for instance-level secrets such as DB passwords and Newt credentials. + /// + /// The ID of the created secret. + Task CreateInstanceSecretAsync(string key, string value, string note = ""); + /// /// Retrieves a secret by its Bitwarden ID. /// @@ -26,6 +33,11 @@ public interface IBitwardenSecretService /// Task UpdateSecretAsync(string secretId, string key, string value, string note = ""); + /// + /// Updates the value of an existing instance-level secret in place (uses instance project if configured). + /// + Task UpdateInstanceSecretAsync(string secretId, string key, string value, string note = ""); + /// /// Lists all secrets in the configured project. /// diff --git a/OTSSignsOrchestrator.Core/Services/IDockerCliService.cs b/OTSSignsOrchestrator.Core/Services/IDockerCliService.cs index 7438ca5..c1edd75 100644 --- a/OTSSignsOrchestrator.Core/Services/IDockerCliService.cs +++ b/OTSSignsOrchestrator.Core/Services/IDockerCliService.cs @@ -3,6 +3,9 @@ using OTSSignsOrchestrator.Core.Models.DTOs; namespace OTSSignsOrchestrator.Core.Services; +// Re-export for convenience so consumers only need one using +using ServiceLogEntry = OTSSignsOrchestrator.Core.Models.DTOs.ServiceLogEntry; + /// /// Abstraction for Docker CLI stack operations (deploy, remove, list, inspect). /// Implementations may use local docker CLI or SSH-based remote execution. @@ -87,6 +90,13 @@ public interface IDockerCliService /// when null, keeping the same /run/secrets/ filename). /// Task ServiceSwapSecretAsync(string serviceName, string oldSecretName, string newSecretName, string? targetAlias = null); + + /// + /// Fetches the last log lines from a Docker Swarm service. + /// If is null, fetches logs from all services in the stack. + /// Returns parsed log entries sorted by timestamp ascending. + /// + Task> GetServiceLogsAsync(string stackName, string? serviceName = null, int tailLines = 200); } public class StackInfo diff --git a/OTSSignsOrchestrator.Core/Services/InstanceService.cs b/OTSSignsOrchestrator.Core/Services/InstanceService.cs index 7750540..4aac016 100644 --- a/OTSSignsOrchestrator.Core/Services/InstanceService.cs +++ b/OTSSignsOrchestrator.Core/Services/InstanceService.cs @@ -99,10 +99,22 @@ public class InstanceService _logger.LogInformation("Fetching template repo: {RepoUrl}", repoUrl); var templateConfig = await _git.FetchAsync(repoUrl, repoPat); - // ── 1b. Remove any stale stack that might hold references to old secrets ─ - _logger.LogInformation("Removing stale stack (if any): {StackName}", stackName); - await _docker.RemoveStackAsync(stackName); - await Task.Delay(2000); + // ── 1b. Remove stale stack (and optionally its cached volumes) ───── + // docker stack rm alone leaves named volumes behind; those volumes + // retain their old driver_opts and Docker re-uses them on the next + // deploy, ignoring the new (correct) options in the compose file. + // PurgeStaleVolumes must be explicitly opted into to avoid accidental data loss. + if (dto.PurgeStaleVolumes) + { + _logger.LogInformation("Purging stale stack and volumes (PurgeStaleVolumes=true): {StackName}", stackName); + await _docker.RemoveStackVolumesAsync(stackName); + } + else + { + _logger.LogInformation("Removing stale stack (if any): {StackName}", stackName); + await _docker.RemoveStackAsync(stackName); + await Task.Delay(2000); + } // ── 2. Generate MySQL credentials ────────────────────────────── var mysqlPassword = GenerateRandomPassword(32); @@ -129,12 +141,11 @@ public class InstanceService throw new InvalidOperationException($"MySQL database/user setup failed: {mysqlMsg}"); _logger.LogInformation("MySQL setup complete: {Message}", mysqlMsg); - // ── 2c. Persist password (encrypted) for future redeploys ──────── + // ── 2c. Persist password for future redeploys ──────── await _settings.SetAsync( SettingsService.InstanceMySqlPassword(abbrev), mysqlPassword, SettingsService.CatInstance, isSensitive: true); - await _db.SaveChangesAsync(); - _logger.LogInformation("MySQL password stored in settings for instance {Abbrev}", abbrev); + _logger.LogInformation("MySQL password stored in Bitwarden for instance {Abbrev}", abbrev); // ── 3. Read settings ──────────────────────────────────────────── var mySqlHost = mySqlHostValue; @@ -238,11 +249,7 @@ public class InstanceService + "(2) NFS export has root_squash enabled — set 'No mapping' / no_root_squash on the NFS server."); } - // ── 6. Remove stale NFS volumes ───────────────────────────────── - _logger.LogInformation("Removing stale NFS volumes for stack {StackName}", stackName); - await _docker.RemoveStackVolumesAsync(stackName); - - // ── 7. Deploy stack ───────────────────────────────────────────── + // ── 6. Deploy stack ───────────────────────────────────────────── var deployResult = await _docker.DeployStackAsync(stackName, composeYaml); if (!deployResult.Success) throw new InvalidOperationException($"Stack deployment failed: {deployResult.ErrorMessage}"); @@ -258,13 +265,15 @@ 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)); + // ── 7. Return result — post-init will be triggered by the UI ────── + // after the user creates an OAuth2 app in the Xibo web UI and supplies + // the client_id and client_secret. + var instanceUrl = $"https://{cmsServerName}/{abbrev}"; + deployResult.InstanceUrl = instanceUrl; + deployResult.Abbrev = abbrev; deployResult.ServiceCount = 4; - deployResult.Message = "Instance deployed successfully. Post-install setup is running in background."; + deployResult.Message = "Instance deployed successfully. Complete post-install setup by providing OAuth credentials."; return deployResult; } catch (Exception ex) diff --git a/OTSSignsOrchestrator.Core/Services/PostInstanceInitService.cs b/OTSSignsOrchestrator.Core/Services/PostInstanceInitService.cs index ca596c7..ab5e919 100644 --- a/OTSSignsOrchestrator.Core/Services/PostInstanceInitService.cs +++ b/OTSSignsOrchestrator.Core/Services/PostInstanceInitService.cs @@ -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: /// /// Waits for the Xibo web service to become available. +/// Authenticates using the OAuth2 application credentials supplied by the user. /// 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. +/// Deletes the default xibo_admin account. /// /// -/// Invoked as a background fire-and-forget task from . +/// The user must first create an OAuth2 application (client_credentials) in the +/// Xibo web UI using the default xibo_admin / password 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. /// +/// public class PostInstanceInitService { private readonly IServiceProvider _services; @@ -41,9 +47,14 @@ public class PostInstanceInitService /// /// 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. /// - 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(); 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); @@ -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) + // ───────────────────────────────────────────────────────────────────────── + + /// + /// Initialises a Xibo CMS instance using OAuth2 credentials supplied by the user. + /// Unlike , 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. + /// + 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(); + var bws = scope.ServiceProvider.GetRequiredService(); + var settings = scope.ServiceProvider.GetRequiredService(); + + // ── 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(); 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); + // 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; } /// - /// Returns the stored admin password for an instance (from Bitwarden or local AppSettings). + /// Returns the stored admin password for an instance from Bitwarden. /// public async Task 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 GetXiboUserIdAsync( - XiboApiService xibo, - string instanceUrl, - string clientId, - string clientSecret, - string targetUsername) + // ───────────────────────────────────────────────────────────────────────── + // Import existing instance secrets on startup + // ───────────────────────────────────────────────────────────────────────── + + /// + /// Scans all Bitwarden secrets for existing instance-level credentials + /// (matching the {abbrev}/xibo-admin-password and {abbrev}/xibo-oauth-secret + /// 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. + /// + 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("grant_type", "client_credentials"), - new KeyValuePair("client_id", clientId), - new KeyValuePair("client_secret", clientSecret), - }); + using var scope = _services.CreateScope(); + var bws = scope.ServiceProvider.GetRequiredService(); + var settings = scope.ServiceProvider.GetRequiredService(); - 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."); } } diff --git a/OTSSignsOrchestrator.Core/Services/SettingsService.cs b/OTSSignsOrchestrator.Core/Services/SettingsService.cs index 8103db2..6a9de53 100644 --- a/OTSSignsOrchestrator.Core/Services/SettingsService.cs +++ b/OTSSignsOrchestrator.Core/Services/SettingsService.cs @@ -1,21 +1,20 @@ -using Microsoft.AspNetCore.DataProtection; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; -using OTSSignsOrchestrator.Core.Data; -using OTSSignsOrchestrator.Core.Models.Entities; namespace OTSSignsOrchestrator.Core.Services; /// -/// Reads and writes typed application settings from the AppSetting table. -/// Sensitive values are encrypted/decrypted transparently via DataProtection. +/// Reads and writes application settings from Bitwarden Secrets Manager. +/// Each setting is stored as a Bitwarden secret with key prefix "ots-config/". +/// The secret's Note field stores metadata (category|isSensitive). /// public class SettingsService { - private readonly XiboContext _db; - private readonly IDataProtector _protector; + private readonly IBitwardenSecretService _bws; private readonly ILogger _logger; + /// Prefix applied to all config secret keys in Bitwarden. + private const string KeyPrefix = "ots-config/"; + // ── Category constants ───────────────────────────────────────────────── public const string CatGit = "Git"; public const string CatMySql = "MySql"; @@ -68,14 +67,6 @@ 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"; @@ -84,7 +75,6 @@ public class SettingsService // 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"; /// Bitwarden secret ID for the instance's OTS admin password. @@ -95,79 +85,169 @@ public class SettingsService public static string InstanceOAuthClientId(string abbrev) => $"Instance.{abbrev}.OAuthClientId"; public const string CatInstance = "Instance"; + // ── In-memory cache of secrets (loaded on first access) ──────────────── + // Maps Bitwarden secret key (with prefix) → (id, value) + // Static so the cache is shared across all transient SettingsService instances. + private static Dictionary? s_cache; + public SettingsService( - XiboContext db, - IDataProtectionProvider dataProtection, + IBitwardenSecretService bws, ILogger logger) { - _db = db; - _protector = dataProtection.CreateProtector("OTSSignsOrchestrator.Settings"); + _bws = bws; _logger = logger; } - /// Get a single setting value, decrypting if sensitive. + /// Get a single setting value from Bitwarden. public async Task GetAsync(string key) { - var setting = await _db.AppSettings.FindAsync(key); - if (setting == null) return null; - return setting.IsSensitive && setting.Value != null - ? Unprotect(setting.Value) - : setting.Value; + var cache = await EnsureCacheAsync(); + var bwKey = KeyPrefix + key; + if (!cache.TryGetValue(bwKey, out var entry)) + return null; + // Treat single-space sentinel as empty (used to work around SDK marshalling limitation) + return string.IsNullOrWhiteSpace(entry.Value) ? null : entry.Value; } /// Get a setting with a fallback default. public async Task GetAsync(string key, string defaultValue) => await GetAsync(key) ?? defaultValue; - /// Set a single setting, encrypting if sensitive. + /// Set a single setting in Bitwarden (creates or updates). public async Task SetAsync(string key, string? value, string category, bool isSensitive = false) { - var setting = await _db.AppSettings.FindAsync(key); - if (setting == null) - { - setting = new AppSetting { Key = key, Category = category, IsSensitive = isSensitive }; - _db.AppSettings.Add(setting); - } + var cache = await EnsureCacheAsync(); + var bwKey = KeyPrefix + key; + var note = $"{category}|{(isSensitive ? "sensitive" : "plain")}"; + // Use a single space for empty/null values — the Bitwarden SDK native FFI + // cannot marshal empty strings reliably. + var safeValue = string.IsNullOrEmpty(value) ? " " : value; - setting.Value = isSensitive && value != null ? _protector.Protect(value) : value; - setting.IsSensitive = isSensitive; - setting.Category = category; - setting.UpdatedAt = DateTime.UtcNow; + if (cache.TryGetValue(bwKey, out var existing)) + { + // Update existing secret + await _bws.UpdateSecretAsync(existing.Id, bwKey, safeValue, note); + cache[bwKey] = (existing.Id, safeValue); + s_cache = cache; + } + else if (!string.IsNullOrWhiteSpace(value)) + { + // Only create new secrets when there is an actual value to store + var newId = await _bws.CreateSecretAsync(bwKey, safeValue, note); + cache[bwKey] = (newId, safeValue); + s_cache = cache; + } } - /// Save multiple settings in a single transaction. + /// Save multiple settings in a batch. public async Task SaveManyAsync(IEnumerable<(string Key, string? Value, string Category, bool IsSensitive)> settings) { + var count = 0; + var errors = new List(); foreach (var (key, value, category, isSensitive) in settings) - await SetAsync(key, value, category, isSensitive); + { + try + { + await SetAsync(key, value, category, isSensitive); + count++; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to save setting {Key}", key); + errors.Add(key); + } + } - await _db.SaveChangesAsync(); - _logger.LogInformation("Saved {Count} setting(s)", - settings is ICollection<(string, string?, string, bool)> c ? c.Count : -1); + _logger.LogInformation("Saved {Count} setting(s) to Bitwarden", count); + + if (errors.Count > 0) + throw new AggregateException( + $"Failed to save {errors.Count} setting(s): {string.Join(", ", errors)}"); } - /// Get all settings in a category (values decrypted). + /// Get all settings in a category (by examining cached keys). public async Task> GetCategoryAsync(string category) { - var settings = await _db.AppSettings - .Where(s => s.Category == category) - .ToListAsync(); + var cache = await EnsureCacheAsync(); + var prefix = KeyPrefix + category + "."; + var result = new Dictionary(); - return settings.ToDictionary( - s => s.Key, - s => s.IsSensitive && s.Value != null ? Unprotect(s.Value) : s.Value); + foreach (var (bwKey, entry) in cache) + { + if (bwKey.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + // Strip the "ots-config/" prefix to return the original key + var originalKey = bwKey[KeyPrefix.Length..]; + result[originalKey] = entry.Value; + } + } + + return result; } - private string? Unprotect(string protectedValue) + /// + /// Invalidates the in-memory cache so next access re-fetches from Bitwarden. + /// + public void InvalidateCache() => s_cache = null; + + /// + /// Pre-loads the settings cache from Bitwarden. + /// Call once at startup so settings are available immediately. + /// + public async Task PreloadCacheAsync() { + InvalidateCache(); + await EnsureCacheAsync(); + } + + // ───────────────────────────────────────────────────────────────────────── + // Cache management + // ───────────────────────────────────────────────────────────────────────── + + private async Task> EnsureCacheAsync() + { + if (s_cache is not null) + return s_cache; + + var cache = new Dictionary(StringComparer.OrdinalIgnoreCase); + + // Skip loading if Bitwarden is not yet configured (normal on first run) + if (!await _bws.IsConfiguredAsync()) + { + _logger.LogInformation("Bitwarden is not configured yet — settings will be available after setup"); + s_cache = cache; + return s_cache; + } + try { - return _protector.Unprotect(protectedValue); + // List all secrets, then fetch full value for those matching our prefix + var summaries = await _bws.ListSecretsAsync(); + var configSecrets = summaries + .Where(s => s.Key.StartsWith(KeyPrefix, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + _logger.LogInformation("Loading {Count} config secrets from Bitwarden", configSecrets.Count); + + foreach (var summary in configSecrets) + { + try + { + var full = await _bws.GetSecretAsync(summary.Id); + cache[full.Key] = (full.Id, full.Value); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to load secret {Key} ({Id})", summary.Key, summary.Id); + } + } } catch (Exception ex) { - _logger.LogWarning(ex, "Failed to unprotect setting value — returning null"); - return null; + _logger.LogError(ex, "Failed to load settings from Bitwarden — settings will be empty until Bitwarden is configured"); } + + s_cache = cache; + return s_cache; } } diff --git a/OTSSignsOrchestrator.Core/Services/XiboApiService.cs b/OTSSignsOrchestrator.Core/Services/XiboApiService.cs index 569c131..5e0a078 100644 --- a/OTSSignsOrchestrator.Core/Services/XiboApiService.cs +++ b/OTSSignsOrchestrator.Core/Services/XiboApiService.cs @@ -12,10 +12,10 @@ namespace OTSSignsOrchestrator.Core.Services; /// 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. +/// 1. After a new instance is deployed, calls +/// with the default Xibo admin credentials to obtain a session cookie. +/// 2. Subsequent operations (create user, register OAuth2 app, set theme) authenticate +/// using that session cookie — no pre-existing OAuth2 application is required. /// public class XiboApiService { @@ -74,35 +74,90 @@ public class XiboApiService } } + // ───────────────────────────────────────────────────────────────────────── + // Session login + // ───────────────────────────────────────────────────────────────────────── + + /// + /// Obtains a Bearer access token using the OAuth2 client_credentials grant. + /// The caller must have previously created an OAuth2 application in the Xibo CMS + /// admin UI and provide the resulting and + /// . + /// + public async Task LoginAsync(string instanceUrl, string clientId, string clientSecret) + { + var baseUrl = instanceUrl.TrimEnd('/'); + var tokenUrl = $"{baseUrl}/api/authorize/access_token"; + var client = _httpClientFactory.CreateClient("XiboApi"); + client.Timeout = TimeSpan.FromSeconds(30); + + 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) + { + var body = await response.Content.ReadAsStringAsync(); + throw new XiboAuthException( + $"Xibo client_credentials login failed for client '{clientId}': HTTP {(int)response.StatusCode} — {body}", + (int)response.StatusCode); + } + + using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync()); + var accessToken = doc.RootElement.GetProperty("access_token").GetString() + ?? throw new InvalidOperationException("access_token missing in Xibo token response."); + + _logger.LogInformation("Xibo access token obtained for client '{ClientId}' at {Url}", clientId, baseUrl); + return accessToken; + } + // ───────────────────────────────────────────────────────────────────────── // Health / readiness // ───────────────────────────────────────────────────────────────────────── /// - /// Polls until Xibo returns a 200 from its - /// /about endpoint or elapses. + /// Polls until Xibo is genuinely online by calling + /// the public /about page (no auth required) and confirming the response body + /// contains the word "Xibo". must already include the + /// instance sub-path (e.g. https://ots.ots-signs.com/ots). + /// The JSON /api/about and /api/clock endpoints both require auth, so + /// the HTML about page is the only reliable unauthenticated Xibo-specific probe. + /// A plain 200 from a proxy is not sufficient — the body must contain "Xibo". /// 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"); + var deadline = DateTime.UtcNow + timeout; + var baseUrl = instanceUrl.TrimEnd('/'); + var client = _httpClientFactory.CreateClient("XiboHealth"); client.Timeout = TimeSpan.FromSeconds(10); + var healthUrl = $"{baseUrl}/about"; - _logger.LogInformation("Waiting for Xibo instance to become ready: {Url}", baseUrl); + _logger.LogInformation("Waiting for Xibo instance to become ready: {Url}", healthUrl); while (DateTime.UtcNow < deadline && !ct.IsCancellationRequested) { try { - var response = await client.GetAsync($"{baseUrl}/api/about", ct); + var response = await client.GetAsync(healthUrl, ct); if (response.IsSuccessStatusCode) { - _logger.LogInformation("Xibo is ready: {Url}", baseUrl); - return true; + // The public /about page always contains the word "Xibo" in its HTML + // when Xibo itself is serving responses. A proxy 200 page will not. + var body = await response.Content.ReadAsStringAsync(ct); + if (body.Contains("Xibo", StringComparison.OrdinalIgnoreCase)) + { + _logger.LogInformation("Xibo is ready: {Url}", healthUrl); + return true; + } + _logger.LogDebug("About page returned 200 but body lacks 'Xibo' — proxy may be up but Xibo not yet ready"); } } catch { /* not yet available */ } @@ -110,7 +165,7 @@ public class XiboApiService await Task.Delay(TimeSpan.FromSeconds(10), ct); } - _logger.LogWarning("Xibo did not become ready within {Timeout}: {Url}", timeout, baseUrl); + _logger.LogWarning("Xibo did not become ready within {Timeout}: {Url}", timeout, healthUrl); return false; } @@ -120,31 +175,33 @@ public class XiboApiService /// /// Creates a new super-admin user in the Xibo instance and returns its numeric ID. + /// Authenticates using the supplied (Bearer token + /// obtained from ). /// public async Task CreateAdminUserAsync( string instanceUrl, - string bootstrapClientId, - string bootstrapClientSecret, + string accessToken, string newUsername, string newPassword, - string email) + string email, + int groupId) { var client = _httpClientFactory.CreateClient("XiboApi"); var baseUrl = instanceUrl.TrimEnd('/'); - var token = await GetTokenAsync(baseUrl, bootstrapClientId, bootstrapClientSecret, client); - SetBearer(client, token); + SetBearer(client, accessToken); 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("homePageId", "icondashboard.view"), new KeyValuePair("libraryQuota", "0"), - new KeyValuePair("groupId", "1"), - new KeyValuePair("newUserPassword", newPassword), - new KeyValuePair("retypeNewUserPassword", newPassword), + new KeyValuePair("groupId", groupId.ToString()), + new KeyValuePair("password", newPassword), + new KeyValuePair("newUserWizard", "0"), + new KeyValuePair("hideNavigation", "0"), new KeyValuePair("isPasswordChangeRequired", "0"), }); @@ -160,19 +217,19 @@ public class XiboApiService /// /// Changes the password of an existing Xibo user. + /// Authenticates using the supplied (Bearer token + /// obtained from ). /// public async Task RotateUserPasswordAsync( string instanceUrl, - string bootstrapClientId, - string bootstrapClientSecret, + string accessToken, int userId, string newPassword) { var client = _httpClientFactory.CreateClient("XiboApi"); var baseUrl = instanceUrl.TrimEnd('/'); - var token = await GetTokenAsync(baseUrl, bootstrapClientId, bootstrapClientSecret, client); - SetBearer(client, token); + SetBearer(client, accessToken); var form = new FormUrlEncodedContent(new[] { @@ -193,18 +250,18 @@ public class XiboApiService /// /// Registers a new client_credentials OAuth2 application in Xibo and returns /// the generated client_id and client_secret. + /// Authenticates using the supplied (Bearer token + /// obtained from ). /// public async Task<(string ClientId, string ClientSecret)> RegisterOAuthClientAsync( string instanceUrl, - string bootstrapClientId, - string bootstrapClientSecret, + string accessToken, string appName) { var client = _httpClientFactory.CreateClient("XiboApi"); var baseUrl = instanceUrl.TrimEnd('/'); - var token = await GetTokenAsync(baseUrl, bootstrapClientId, bootstrapClientSecret, client); - SetBearer(client, token); + SetBearer(client, accessToken); var form = new FormUrlEncodedContent(new[] { @@ -235,18 +292,18 @@ public class XiboApiService /// /// Sets the active CMS theme by writing the THEME_FOLDER setting. + /// Authenticates using the supplied (Bearer token + /// obtained from ). /// public async Task SetThemeAsync( string instanceUrl, - string bootstrapClientId, - string bootstrapClientSecret, + string accessToken, string themeFolderName = "otssigns") { var client = _httpClientFactory.CreateClient("XiboApi"); var baseUrl = instanceUrl.TrimEnd('/'); - var token = await GetTokenAsync(baseUrl, bootstrapClientId, bootstrapClientSecret, client); - SetBearer(client, token); + SetBearer(client, accessToken); // Xibo stores settings as an array: settings[THEME_FOLDER]=otssigns var form = new FormUrlEncodedContent(new[] @@ -260,6 +317,152 @@ public class XiboApiService _logger.LogInformation("Xibo theme set to: {Theme}", themeFolderName); } + // ───────────────────────────────────────────────────────────────────────── + // User groups + // ───────────────────────────────────────────────────────────────────────── + + /// + /// Creates a new user group and returns its numeric group ID. + /// + public async Task CreateUserGroupAsync( + string instanceUrl, + string accessToken, + string groupName) + { + var client = _httpClientFactory.CreateClient("XiboApi"); + var baseUrl = instanceUrl.TrimEnd('/'); + + SetBearer(client, accessToken); + + var form = new FormUrlEncodedContent(new[] + { + new KeyValuePair("group", groupName), + new KeyValuePair("libraryQuota", "0"), + }); + + var response = await client.PostAsync($"{baseUrl}/api/group", form); + await EnsureSuccessAsync(response, "create Xibo user group"); + + // The response is an array containing the created group + using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync()); + var root = doc.RootElement; + // Response may be an array or a single object depending on Xibo version + var groupEl = root.ValueKind == JsonValueKind.Array ? root[0] : root; + var gid = groupEl.GetProperty("groupId").GetInt32(); + + _logger.LogInformation("Xibo user group created: name={Name}, groupId={GroupId}", groupName, gid); + return gid; + } + + /// + /// Assigns a user to a Xibo user group. + /// + public async Task AssignUserToGroupAsync( + string instanceUrl, + string accessToken, + int groupId, + int userId) + { + var client = _httpClientFactory.CreateClient("XiboApi"); + var baseUrl = instanceUrl.TrimEnd('/'); + + SetBearer(client, accessToken); + + var form = new FormUrlEncodedContent(new[] + { + new KeyValuePair("userId[]", userId.ToString()), + }); + + var response = await client.PostAsync($"{baseUrl}/api/group/members/assign/{groupId}", form); + await EnsureSuccessAsync(response, $"assign user {userId} to group {groupId}"); + + _logger.LogInformation("User {UserId} assigned to group {GroupId}", userId, groupId); + } + + // ───────────────────────────────────────────────────────────────────────── + // User lookup / update / deletion + // ───────────────────────────────────────────────────────────────────────── + + /// + /// Updates an existing Xibo user's username, password, and email. + /// Authenticates using the supplied . + /// + public async Task UpdateUserAsync( + string instanceUrl, + string accessToken, + int userId, + string newUsername, + string newPassword, + string email) + { + var client = _httpClientFactory.CreateClient("XiboApi"); + var baseUrl = instanceUrl.TrimEnd('/'); + + SetBearer(client, accessToken); + + var form = new FormUrlEncodedContent(new[] + { + new KeyValuePair("userName", newUsername), + new KeyValuePair("email", email), + new KeyValuePair("userTypeId", "1"), + new KeyValuePair("homePageId", "icondashboard.view"), + new KeyValuePair("libraryQuota", "0"), + new KeyValuePair("newPassword", newPassword), + new KeyValuePair("retypeNewPassword", newPassword), + new KeyValuePair("newUserWizard", "0"), + new KeyValuePair("hideNavigation", "0"), + new KeyValuePair("isPasswordChangeRequired", "0"), + }); + + var response = await client.PutAsync($"{baseUrl}/api/user/{userId}", form); + await EnsureSuccessAsync(response, $"update Xibo user {userId}"); + + _logger.LogInformation("Xibo user updated: userId={UserId}, newUsername={Username}", userId, newUsername); + } + + /// + /// Finds a Xibo user by username and returns their numeric user ID. + /// Authenticates using the supplied . + /// + public async Task GetUserIdByNameAsync(string instanceUrl, string accessToken, string username) + { + var client = _httpClientFactory.CreateClient("XiboApi"); + var baseUrl = instanceUrl.TrimEnd('/'); + + SetBearer(client, accessToken); + + var response = await client.GetAsync($"{baseUrl}/api/user?userName={Uri.EscapeDataString(username)}"); + await EnsureSuccessAsync(response, "look up Xibo user by name"); + + using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync()); + foreach (var user in doc.RootElement.EnumerateArray()) + { + var name = user.GetProperty("userName").GetString(); + if (string.Equals(name, username, StringComparison.OrdinalIgnoreCase)) + return user.GetProperty("userId").GetInt32(); + } + + throw new InvalidOperationException( + $"Xibo user '{username}' not found."); + } + + /// + /// Deletes a Xibo user by their numeric user ID. + /// Authenticates using the supplied . + /// + public async Task DeleteUserAsync(string instanceUrl, string accessToken, int userId) + { + var client = _httpClientFactory.CreateClient("XiboApi"); + var baseUrl = instanceUrl.TrimEnd('/'); + + SetBearer(client, accessToken); + + var response = await client.DeleteAsync($"{baseUrl}/api/user/{userId}"); + await EnsureSuccessAsync(response, $"delete Xibo user {userId}"); + + _logger.LogInformation("Xibo user deleted: userId={UserId}", userId); + } + // ───────────────────────────────────────────────────────────────────────── // Helpers // ───────────────────────────────────────────────────────────────────────── @@ -302,6 +505,8 @@ public class XiboApiService 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) diff --git a/OTSSignsOrchestrator.Desktop/App.axaml.cs b/OTSSignsOrchestrator.Desktop/App.axaml.cs index 7f848a7..04e1854 100644 --- a/OTSSignsOrchestrator.Desktop/App.axaml.cs +++ b/OTSSignsOrchestrator.Desktop/App.axaml.cs @@ -44,6 +44,28 @@ public class App : Application if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { Log.Information("Creating MainWindow..."); + + // Import existing instance secrets from Bitwarden (fire-and-forget, non-blocking) + _ = Task.Run(async () => + { + try + { + // Pre-load config settings from Bitwarden so they're available immediately + using var scope = Services.CreateScope(); + var settings = scope.ServiceProvider.GetRequiredService(); + await settings.PreloadCacheAsync(); + Log.Information("Bitwarden config settings pre-loaded"); + + // Import existing instance secrets that aren't yet tracked + var postInit = Services.GetRequiredService(); + await postInit.ImportExistingInstanceSecretsAsync(); + } + catch (Exception ex) + { + Log.Warning(ex, "Failed to pre-load settings or import instance secrets on startup"); + } + }); + var vm = Services.GetRequiredService(); Log.Information("MainWindowViewModel resolved"); @@ -75,10 +97,10 @@ public class App : Application private static void ConfigureServices(IServiceCollection services) { - // Configuration + // Configuration (reloadOnChange so runtime writes to appsettings.json are picked up) var config = new ConfigurationBuilder() .SetBasePath(AppContext.BaseDirectory) - .AddJsonFile("appsettings.json", optional: false) + .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) .Build(); services.AddSingleton(config); @@ -89,6 +111,7 @@ public class App : Application services.Configure(config.GetSection(XiboOptions.SectionName)); services.Configure(config.GetSection(DatabaseOptions.SectionName)); services.Configure(config.GetSection(FileLoggingOptions.SectionName)); + services.Configure(config.GetSection(BitwardenOptions.SectionName)); // Logging services.AddLogging(builder => @@ -115,7 +138,6 @@ public class App : Application services.AddHttpClient(); services.AddHttpClient("XiboApi"); services.AddHttpClient("XiboHealth"); - services.AddHttpClient("Bitwarden"); // SSH services (singletons — maintain connections) services.AddSingleton(); @@ -137,7 +159,7 @@ public class App : Application services.AddSingleton(); // ViewModels - services.AddTransient(); + services.AddSingleton(); // singleton: one main window, nav state shared services.AddTransient(); services.AddTransient(); services.AddTransient(); diff --git a/OTSSignsOrchestrator.Desktop/OTSSignsOrchestrator.Desktop.csproj b/OTSSignsOrchestrator.Desktop/OTSSignsOrchestrator.Desktop.csproj index a07e0ea..dae799d 100644 --- a/OTSSignsOrchestrator.Desktop/OTSSignsOrchestrator.Desktop.csproj +++ b/OTSSignsOrchestrator.Desktop/OTSSignsOrchestrator.Desktop.csproj @@ -8,6 +8,8 @@ true app.manifest true + + linux-x64;win-x64;osx-x64;osx-arm64 diff --git a/OTSSignsOrchestrator.Desktop/Services/SshDockerCliService.cs b/OTSSignsOrchestrator.Desktop/Services/SshDockerCliService.cs index 18a1b3b..55aed34 100644 --- a/OTSSignsOrchestrator.Desktop/Services/SshDockerCliService.cs +++ b/OTSSignsOrchestrator.Desktop/Services/SshDockerCliService.cs @@ -6,6 +6,7 @@ using OTSSignsOrchestrator.Core.Configuration; using OTSSignsOrchestrator.Core.Models.DTOs; using OTSSignsOrchestrator.Core.Models.Entities; using OTSSignsOrchestrator.Core.Services; +using ServiceLogEntry = OTSSignsOrchestrator.Core.Models.DTOs.ServiceLogEntry; namespace OTSSignsOrchestrator.Desktop.Services; @@ -441,6 +442,121 @@ public class SshDockerCliService : IDockerCliService .ToList(); } + public async Task> GetServiceLogsAsync(string stackName, string? serviceName = null, int tailLines = 200) + { + EnsureHost(); + + // Determine which services to fetch logs for + List serviceNames; + if (!string.IsNullOrEmpty(serviceName)) + { + serviceNames = new List { serviceName }; + } + else + { + var services = await InspectStackServicesAsync(stackName); + serviceNames = services.Select(s => s.Name).ToList(); + } + + var allEntries = new List(); + foreach (var svcName in serviceNames) + { + try + { + var cmd = $"docker service logs --timestamps --no-trunc --tail {tailLines} {svcName} 2>&1"; + var (exitCode, stdout, _) = await _ssh.RunCommandAsync(_currentHost!, cmd, TimeSpan.FromSeconds(15)); + + if (exitCode != 0 || string.IsNullOrWhiteSpace(stdout)) + { + _logger.LogDebug("No logs returned for service {Service} (exit={ExitCode})", svcName, exitCode); + continue; + } + + // Parse each line. Docker service logs format with --timestamps: + // ..@ | + // or sometimes just: + // .. + foreach (var line in stdout.Split('\n', StringSplitOptions.RemoveEmptyEntries)) + { + var entry = ParseLogLine(line, svcName, stackName); + if (entry != null) + allEntries.Add(entry); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to fetch logs for service {Service}", svcName); + } + } + + return allEntries.OrderBy(e => e.Timestamp).ToList(); + } + + /// + /// Parses a single line from docker service logs --timestamps output. + /// + private static ServiceLogEntry? ParseLogLine(string line, string serviceName, string stackName) + { + if (string.IsNullOrWhiteSpace(line)) + return null; + + // Format: "2026-02-25T14:30:45.123456789Z service.replica.taskid@node | message" + // The timestamp is always the first space-delimited token when --timestamps is used. + var firstSpace = line.IndexOf(' '); + if (firstSpace <= 0) + return new ServiceLogEntry + { + Timestamp = DateTimeOffset.UtcNow, + Source = serviceName, + ServiceName = StripStackPrefix(serviceName, stackName), + Message = line + }; + + var timestampStr = line[..firstSpace]; + var rest = line[(firstSpace + 1)..].TrimStart(); + + // Try to parse the timestamp + if (!DateTimeOffset.TryParse(timestampStr, out var timestamp)) + { + // If timestamp parsing fails, treat the whole line as the message + return new ServiceLogEntry + { + Timestamp = DateTimeOffset.UtcNow, + Source = serviceName, + ServiceName = StripStackPrefix(serviceName, stackName), + Message = line + }; + } + + // Split source and message on the pipe separator + var source = serviceName; + var message = rest; + var pipeIndex = rest.IndexOf('|'); + if (pipeIndex >= 0) + { + source = rest[..pipeIndex].Trim(); + message = rest[(pipeIndex + 1)..].TrimStart(); + } + + return new ServiceLogEntry + { + Timestamp = timestamp, + Source = source, + ServiceName = StripStackPrefix(serviceName, stackName), + Message = message + }; + } + + /// + /// Strips the stack name prefix from a fully-qualified service name. + /// e.g. "acm-cms-stack_acm-web" → "acm-web" + /// + private static string StripStackPrefix(string serviceName, string stackName) + { + var prefix = stackName + "_"; + return serviceName.StartsWith(prefix) ? serviceName[prefix.Length..] : serviceName; + } + private void EnsureHost() { if (_currentHost == null) diff --git a/OTSSignsOrchestrator.Desktop/ViewModels/CreateInstanceViewModel.cs b/OTSSignsOrchestrator.Desktop/ViewModels/CreateInstanceViewModel.cs index 4d028e6..6c98797 100644 --- a/OTSSignsOrchestrator.Desktop/ViewModels/CreateInstanceViewModel.cs +++ b/OTSSignsOrchestrator.Desktop/ViewModels/CreateInstanceViewModel.cs @@ -22,6 +22,7 @@ namespace OTSSignsOrchestrator.Desktop.ViewModels; public partial class CreateInstanceViewModel : ObservableObject { private readonly IServiceProvider _services; + private readonly MainWindowViewModel _mainVm; [ObservableProperty] private string _statusMessage = string.Empty; [ObservableProperty] private bool _isBusy; @@ -43,6 +44,9 @@ public partial class CreateInstanceViewModel : ObservableObject [ObservableProperty] private string _nfsExportFolder = string.Empty; [ObservableProperty] private string _nfsExtraOptions = string.Empty; + /// When enabled, existing Docker volumes for the stack are removed before deploying. + [ObservableProperty] private bool _purgeStaleVolumes = false; + // SSH host selection [ObservableProperty] private ObservableCollection _availableHosts = new(); [ObservableProperty] private SshHost? _selectedSshHost; @@ -80,9 +84,10 @@ public partial class CreateInstanceViewModel : ObservableObject // ───────────────────────────────────────────────────────────────────────── - public CreateInstanceViewModel(IServiceProvider services) + public CreateInstanceViewModel(IServiceProvider services, MainWindowViewModel mainVm) { _services = services; + _mainVm = mainVm; _ = LoadHostsAsync(); _ = LoadNfsDefaultsAsync(); } @@ -304,20 +309,29 @@ public partial class CreateInstanceViewModel : ObservableObject SshHostId = SelectedSshHost.Id, NewtId = string.IsNullOrWhiteSpace(NewtId) ? null : NewtId.Trim(), NewtSecret = string.IsNullOrWhiteSpace(NewtSecret) ? null : NewtSecret.Trim(), - NfsServer = string.IsNullOrWhiteSpace(NfsServer) ? null : NfsServer.Trim(), - NfsExport = string.IsNullOrWhiteSpace(NfsExport) ? null : NfsExport.Trim(), - NfsExportFolder = string.IsNullOrWhiteSpace(NfsExportFolder) ? null : NfsExportFolder.Trim(), - NfsExtraOptions = string.IsNullOrWhiteSpace(NfsExtraOptions) ? null : NfsExtraOptions.Trim(), + NfsServer = string.IsNullOrWhiteSpace(NfsServer) ? null : NfsServer.Trim(), + NfsExport = string.IsNullOrWhiteSpace(NfsExport) ? null : NfsExport.Trim(), + NfsExportFolder = string.IsNullOrWhiteSpace(NfsExportFolder) ? null : NfsExportFolder.Trim(), + NfsExtraOptions = string.IsNullOrWhiteSpace(NfsExtraOptions) ? null : NfsExtraOptions.Trim(), + PurgeStaleVolumes = PurgeStaleVolumes, }; var result = await instanceSvc.CreateInstanceAsync(dto); AppendOutput(result.Output ?? string.Empty); - SetProgress(100, result.Success ? "Deployment complete!" : "Deployment failed."); - StatusMessage = result.Success - ? $"Instance '{Abbrev}-cms-stack' deployed in {result.DurationMs}ms!" - : $"Deploy failed: {result.ErrorMessage}"; + if (result.Success) + { + SetProgress(100, "Stack deployed successfully."); + StatusMessage = $"Instance '{Abbrev}-cms-stack' deployed in {result.DurationMs}ms. " + + "Open the details pane on the Instances page to complete setup."; + _mainVm.NavigateToInstancesWithSelection(Abbrev); + } + else + { + SetProgress(0, "Deployment failed."); + StatusMessage = $"Deploy failed: {result.ErrorMessage}"; + } } catch (Exception ex) { diff --git a/OTSSignsOrchestrator.Desktop/ViewModels/InstanceDetailsViewModel.cs b/OTSSignsOrchestrator.Desktop/ViewModels/InstanceDetailsViewModel.cs index e8e3c8c..2141a8a 100644 --- a/OTSSignsOrchestrator.Desktop/ViewModels/InstanceDetailsViewModel.cs +++ b/OTSSignsOrchestrator.Desktop/ViewModels/InstanceDetailsViewModel.cs @@ -45,7 +45,13 @@ public partial class InstanceDetailsViewModel : ObservableObject // ── Status ──────────────────────────────────────────────────────────────── [ObservableProperty] private string _statusMessage = string.Empty; [ObservableProperty] private bool _isBusy; + // ── Pending-setup inputs (shown when instance hasn't been initialised yet) ──────────── + [ObservableProperty] private bool _isPendingSetup; + [ObservableProperty] private string _initClientId = string.Empty; + [ObservableProperty] private string _initClientSecret = string.Empty; + // Cached instance — needed by InitializeCommand to reload after setup + private LiveStackItem? _currentInstance; public InstanceDetailsViewModel(IServiceProvider services) { _services = services; @@ -58,6 +64,7 @@ public partial class InstanceDetailsViewModel : ObservableObject /// Populates the ViewModel from a live . public async Task LoadAsync(LiveStackItem instance) { + _currentInstance = instance; StackName = instance.StackName; CustomerAbbrev = instance.CustomerAbbrev; HostLabel = instance.HostLabel; @@ -75,7 +82,7 @@ public partial class InstanceDetailsViewModel : ObservableObject var serverTemplate = await settings.GetAsync( SettingsService.DefaultCmsServerNameTemplate, "{abbrev}.ots-signs.com"); var serverName = serverTemplate.Replace("{abbrev}", instance.CustomerAbbrev); - InstanceUrl = $"https://{serverName}"; + InstanceUrl = $"https://{serverName}/{instance.CustomerAbbrev.Trim().ToLowerInvariant()}"; // ── Admin credentials ───────────────────────────────────────── var creds = await postInit.GetCredentialsAsync(instance.CustomerAbbrev); @@ -95,7 +102,15 @@ public partial class InstanceDetailsViewModel : ObservableObject StatusMessage = creds.HasAdminPassword ? "Credentials loaded." - : "Credentials not yet available — post-install setup may still be running."; + : "Pending setup — enter your Xibo OAuth credentials below to initialise this instance."; + + IsPendingSetup = !creds.HasAdminPassword; + // Clear any previous init inputs when re-loading + if (IsPendingSetup) + { + InitClientId = string.Empty; + InitClientSecret = string.Empty; + } } catch (Exception ex) { @@ -107,6 +122,42 @@ public partial class InstanceDetailsViewModel : ObservableObject } } + // ───────────────────────────────────────────────────────────────────────── + // Initialise (pending setup) + // ───────────────────────────────────────────────────────────────────────── + + [RelayCommand] + private async Task InitializeAsync() + { + if (string.IsNullOrWhiteSpace(InitClientId) || string.IsNullOrWhiteSpace(InitClientSecret)) + { + StatusMessage = "Both Client ID and Client Secret are required."; + return; + } + if (_currentInstance is null) return; + + IsBusy = true; + StatusMessage = "Waiting for Xibo and running initialisation (this may take several minutes)..."; + try + { + var postInit = _services.GetRequiredService(); + await postInit.InitializeWithOAuthAsync( + CustomerAbbrev, + InstanceUrl, + InitClientId.Trim(), + InitClientSecret.Trim()); + + // Reload credentials — IsPendingSetup will flip to false + IsBusy = false; + await LoadAsync(_currentInstance); + } + catch (Exception ex) + { + StatusMessage = $"Initialisation failed: {ex.Message}"; + IsBusy = false; + } + } + // ───────────────────────────────────────────────────────────────────────── // Visibility toggles // ───────────────────────────────────────────────────────────────────────── diff --git a/OTSSignsOrchestrator.Desktop/ViewModels/InstancesViewModel.cs b/OTSSignsOrchestrator.Desktop/ViewModels/InstancesViewModel.cs index 850732f..19dc11e 100644 --- a/OTSSignsOrchestrator.Desktop/ViewModels/InstancesViewModel.cs +++ b/OTSSignsOrchestrator.Desktop/ViewModels/InstancesViewModel.cs @@ -1,9 +1,11 @@ using System.Collections.ObjectModel; +using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using OTSSignsOrchestrator.Core.Data; +using OTSSignsOrchestrator.Core.Models.DTOs; using OTSSignsOrchestrator.Core.Models.Entities; using OTSSignsOrchestrator.Core.Services; using OTSSignsOrchestrator.Desktop.Models; @@ -30,15 +32,37 @@ public partial class InstancesViewModel : ObservableObject [ObservableProperty] private ObservableCollection _availableHosts = new(); [ObservableProperty] private SshHost? _selectedSshHost; + // ── Container Logs ────────────────────────────────────────────────────── + [ObservableProperty] private ObservableCollection _logEntries = new(); + [ObservableProperty] private ObservableCollection _logServiceFilter = new(); + [ObservableProperty] private string _selectedLogService = "All Services"; + [ObservableProperty] private bool _isLogsPanelVisible; + [ObservableProperty] private bool _isLogsAutoRefresh = true; + [ObservableProperty] private bool _isLoadingLogs; + [ObservableProperty] private string _logsStatusMessage = string.Empty; + [ObservableProperty] private int _logTailLines = 200; + + private DispatcherTimer? _logRefreshTimer; + private bool _isLogRefreshRunning; + /// Raised when the instance details modal should be opened for the given ViewModel. public event Action? OpenDetailsRequested; + private string? _pendingSelectAbbrev; + public InstancesViewModel(IServiceProvider services) { _services = services; _ = RefreshAllAsync(); } + /// + /// Queues an abbreviation to be auto-selected once the next live refresh completes. + /// Call immediately after construction (before finishes). + /// + public void SetPendingSelection(string abbrev) + => _pendingSelectAbbrev = abbrev; + /// /// Enumerates all SSH hosts, then calls docker stack ls on each to build the /// live instance list. Only stacks matching *-cms-stack are shown. @@ -89,6 +113,15 @@ public partial class InstancesViewModel : ObservableObject i.HostLabel.Contains(FilterText, StringComparison.OrdinalIgnoreCase)).ToList(); Instances = new ObservableCollection(all); + + // Auto-select a pending instance (e.g. just deployed from Create Instance page) + if (_pendingSelectAbbrev is not null) + { + SelectedInstance = all.FirstOrDefault(i => + i.CustomerAbbrev.Equals(_pendingSelectAbbrev, StringComparison.OrdinalIgnoreCase)); + _pendingSelectAbbrev = null; + } + var msg = $"Found {all.Count} instance(s) across {hosts.Count} host(s)."; if (errors.Count > 0) msg += $" | Errors: {string.Join(" | ", errors)}"; StatusMessage = msg; @@ -110,11 +143,108 @@ public partial class InstancesViewModel : ObservableObject var services = await dockerCli.InspectStackServicesAsync(SelectedInstance.StackName); SelectedServices = new ObservableCollection(services); StatusMessage = $"Found {services.Count} service(s) in stack '{SelectedInstance.StackName}'."; + + // Populate service filter dropdown and show logs panel + var filterItems = new List { "All Services" }; + filterItems.AddRange(services.Select(s => s.Name)); + LogServiceFilter = new ObservableCollection(filterItems); + SelectedLogService = "All Services"; + IsLogsPanelVisible = true; + + // Fetch initial logs and start auto-refresh + await FetchLogsInternalAsync(); + StartLogAutoRefresh(); } catch (Exception ex) { StatusMessage = $"Error inspecting: {ex.Message}"; } finally { IsBusy = false; } } + // ── Container Log Commands ────────────────────────────────────────────── + + [RelayCommand] + private async Task RefreshLogsAsync() + { + await FetchLogsInternalAsync(); + } + + [RelayCommand] + private void ToggleLogsAutoRefresh() + { + IsLogsAutoRefresh = !IsLogsAutoRefresh; + if (IsLogsAutoRefresh) + StartLogAutoRefresh(); + else + StopLogAutoRefresh(); + } + + [RelayCommand] + private void CloseLogsPanel() + { + StopLogAutoRefresh(); + IsLogsPanelVisible = false; + LogEntries = new ObservableCollection(); + LogsStatusMessage = string.Empty; + } + + partial void OnSelectedLogServiceChanged(string value) + { + // When user changes the service filter, refresh logs immediately + if (IsLogsPanelVisible) + _ = FetchLogsInternalAsync(); + } + + private async Task FetchLogsInternalAsync() + { + if (SelectedInstance == null || _isLogRefreshRunning) return; + + _isLogRefreshRunning = true; + IsLoadingLogs = true; + try + { + var dockerCli = _services.GetRequiredService(); + dockerCli.SetHost(SelectedInstance.Host); + + string? serviceFilter = SelectedLogService == "All Services" ? null : SelectedLogService; + var entries = await dockerCli.GetServiceLogsAsync( + SelectedInstance.StackName, serviceFilter, LogTailLines); + + LogEntries = new ObservableCollection(entries); + LogsStatusMessage = $"{entries.Count} log line(s) · last fetched {DateTime.Now:HH:mm:ss}"; + } + catch (Exception ex) + { + LogsStatusMessage = $"Error fetching logs: {ex.Message}"; + } + finally + { + IsLoadingLogs = false; + _isLogRefreshRunning = false; + } + } + + private void StartLogAutoRefresh() + { + StopLogAutoRefresh(); + if (!IsLogsAutoRefresh) return; + + _logRefreshTimer = new DispatcherTimer + { + Interval = TimeSpan.FromSeconds(5) + }; + _logRefreshTimer.Tick += async (_, _) => + { + if (IsLogsPanelVisible && IsLogsAutoRefresh && !_isLogRefreshRunning) + await FetchLogsInternalAsync(); + }; + _logRefreshTimer.Start(); + } + + private void StopLogAutoRefresh() + { + _logRefreshTimer?.Stop(); + _logRefreshTimer = null; + } + [RelayCommand] private async Task DeleteInstanceAsync() { diff --git a/OTSSignsOrchestrator.Desktop/ViewModels/MainWindowViewModel.cs b/OTSSignsOrchestrator.Desktop/ViewModels/MainWindowViewModel.cs index 38c823f..396cdf6 100644 --- a/OTSSignsOrchestrator.Desktop/ViewModels/MainWindowViewModel.cs +++ b/OTSSignsOrchestrator.Desktop/ViewModels/MainWindowViewModel.cs @@ -53,6 +53,17 @@ public partial class MainWindowViewModel : ObservableObject }; } + /// + /// Navigates to the Instances page and auto-selects the instance with the given abbreviation + /// once the live refresh completes. + /// + public void NavigateToInstancesWithSelection(string abbrev) + { + SelectedNav = "Instances"; // triggers OnSelectedNavChanged → NavigateTo("Instances") + if (CurrentView is InstancesViewModel instancesVm) + instancesVm.SetPendingSelection(abbrev); + } + public void SetStatus(string message) { StatusMessage = message; diff --git a/OTSSignsOrchestrator.Desktop/ViewModels/SettingsViewModel.cs b/OTSSignsOrchestrator.Desktop/ViewModels/SettingsViewModel.cs index ca277c6..00eb848 100644 --- a/OTSSignsOrchestrator.Desktop/ViewModels/SettingsViewModel.cs +++ b/OTSSignsOrchestrator.Desktop/ViewModels/SettingsViewModel.cs @@ -1,7 +1,11 @@ using System.Collections.ObjectModel; +using System.Text.Json; +using System.Text.Json.Nodes; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using OTSSignsOrchestrator.Core.Configuration; using OTSSignsOrchestrator.Core.Services; namespace OTSSignsOrchestrator.Desktop.ViewModels; @@ -65,6 +69,7 @@ public partial class SettingsViewModel : ObservableObject [ObservableProperty] private string _bitwardenAccessToken = string.Empty; [ObservableProperty] private string _bitwardenOrganizationId = string.Empty; [ObservableProperty] private string _bitwardenProjectId = string.Empty; + [ObservableProperty] private string _bitwardenInstanceProjectId = string.Empty; // ── Xibo Bootstrap OAuth2 ───────────────────────────────────── [ObservableProperty] private string _xiboBootstrapClientId = string.Empty; @@ -76,12 +81,34 @@ public partial class SettingsViewModel : ObservableObject _ = LoadAsync(); } + /// Whether Bitwarden is configured and reachable. + [ObservableProperty] private bool _isBitwardenConfigured; + [RelayCommand] private async Task LoadAsync() { IsBusy = true; try { + // ── Load Bitwarden bootstrap config from IOptions ── + var bwOptions = _services.GetRequiredService>().Value; + BitwardenIdentityUrl = bwOptions.IdentityUrl; + BitwardenApiUrl = bwOptions.ApiUrl; + BitwardenAccessToken = bwOptions.AccessToken; + BitwardenOrganizationId = bwOptions.OrganizationId; + BitwardenProjectId = bwOptions.ProjectId; + BitwardenInstanceProjectId = bwOptions.InstanceProjectId; + + IsBitwardenConfigured = !string.IsNullOrWhiteSpace(bwOptions.AccessToken) + && !string.IsNullOrWhiteSpace(bwOptions.OrganizationId); + + if (!IsBitwardenConfigured) + { + StatusMessage = "Bitwarden is not configured. Fill in the Bitwarden section and save to get started."; + return; + } + + // ── Load all other settings from Bitwarden ── using var scope = _services.CreateScope(); var svc = scope.ServiceProvider.GetRequiredService(); @@ -127,18 +154,11 @@ 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."; + StatusMessage = "Settings loaded from Bitwarden."; } catch (Exception ex) { @@ -156,8 +176,23 @@ public partial class SettingsViewModel : ObservableObject IsBusy = true; try { + // ── 1. Save Bitwarden bootstrap config to appsettings.json ── + await SaveBitwardenConfigToFileAsync(); + + // Check if Bitwarden is now configured + IsBitwardenConfigured = !string.IsNullOrWhiteSpace(BitwardenAccessToken) + && !string.IsNullOrWhiteSpace(BitwardenOrganizationId); + + if (!IsBitwardenConfigured) + { + StatusMessage = "Bitwarden config saved to appsettings.json. Fill in Access Token and Org ID to enable all settings."; + return; + } + + // ── 2. Save all other settings to Bitwarden ── using var scope = _services.CreateScope(); var svc = scope.ServiceProvider.GetRequiredService(); + svc.InvalidateCache(); // force re-read after config change var settings = new List<(string Key, string? Value, string Category, bool IsSensitive)> { @@ -203,20 +238,13 @@ public partial class SettingsViewModel : ObservableObject (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); - StatusMessage = "Settings saved successfully."; + StatusMessage = "Settings saved to Bitwarden."; } catch (Exception ex) { @@ -238,16 +266,21 @@ public partial class SettingsViewModel : ObservableObject } IsBusy = true; - StatusMessage = "Testing Bitwarden Secrets Manager connection..."; + StatusMessage = "Saving Bitwarden config and testing connection..."; try { + // Save to appsettings.json first so the service picks up fresh values + await SaveBitwardenConfigToFileAsync(); + using var scope = _services.CreateScope(); var bws = scope.ServiceProvider.GetRequiredService(); var secrets = await bws.ListSecretsAsync(); + IsBitwardenConfigured = true; StatusMessage = $"Bitwarden connected. Found {secrets.Count} secret(s) in project."; } catch (Exception ex) { + IsBitwardenConfigured = false; StatusMessage = $"Bitwarden connection failed: {ex.Message}"; } finally @@ -324,6 +357,33 @@ public partial class SettingsViewModel : ObservableObject } } + /// + /// Persists Bitwarden bootstrap credentials to appsettings.json so they survive restarts. + /// + private async Task SaveBitwardenConfigToFileAsync() + { + var path = Path.Combine(AppContext.BaseDirectory, "appsettings.json"); + var json = await File.ReadAllTextAsync(path); + var doc = JsonNode.Parse(json, documentOptions: new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip })!; + + var bw = doc["Bitwarden"]?.AsObject(); + if (bw == null) + { + bw = new JsonObject(); + doc.AsObject()["Bitwarden"] = bw; + } + + bw["IdentityUrl"] = BitwardenIdentityUrl; + bw["ApiUrl"] = BitwardenApiUrl; + bw["AccessToken"] = BitwardenAccessToken; + bw["OrganizationId"] = BitwardenOrganizationId; + bw["ProjectId"] = BitwardenProjectId; + bw["InstanceProjectId"] = BitwardenInstanceProjectId; + + var options = new JsonSerializerOptions { WriteIndented = true }; + await File.WriteAllTextAsync(path, doc.ToJsonString(options)); + } + private static string? NullIfEmpty(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); } diff --git a/OTSSignsOrchestrator.Desktop/Views/CreateInstanceView.axaml b/OTSSignsOrchestrator.Desktop/Views/CreateInstanceView.axaml index 080fa6b..7756474 100644 --- a/OTSSignsOrchestrator.Desktop/Views/CreateInstanceView.axaml +++ b/OTSSignsOrchestrator.Desktop/Views/CreateInstanceView.axaml @@ -78,6 +78,20 @@ + + + + + + + + + + + + + +