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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+