feat: Implement container logs functionality in InstancesViewModel
- Added properties for managing container logs, including log entries, service filters, and auto-refresh options. - Introduced commands for refreshing logs, toggling auto-refresh, and closing the logs panel. - Implemented log fetching logic with error handling and status messages. - Integrated log display in the InstancesView with a dedicated logs panel. feat: Enhance navigation to Instances page with auto-selection - Added method to navigate to the Instances page and auto-select an instance based on abbreviation. feat: Update SettingsViewModel to load and save Bitwarden configuration - Integrated Bitwarden configuration loading from IOptions and saving to appsettings.json. - Added properties for Bitwarden instance project ID and connection status. - Updated UI to reflect Bitwarden settings and connection status. feat: Add advanced options for instance creation - Introduced a new expander in CreateInstanceView for advanced options, including purging stale volumes. feat: Improve InstanceDetailsWindow with pending setup banner - Added a banner to indicate pending setup for Xibo OAuth credentials, with editable fields for client ID and secret. fix: Update appsettings.json to include Bitwarden configuration structure - Added Bitwarden section to appsettings.json for storing configuration values. chore: Update Docker Compose template with health checks - Added health check configuration for web service in template.yml to ensure service availability. refactor: Drop AppSettings table from database - Removed AppSettings table and related migration files as part of database cleanup. feat: Create ServiceLogEntry DTO for log management - Added ServiceLogEntry class to represent individual log entries from Docker services.
This commit is contained in:
1
.template-cache/053604496cfa3867
Submodule
1
.template-cache/053604496cfa3867
Submodule
Submodule .template-cache/053604496cfa3867 added at a6ab3c254b
Submodule .template-cache/2dc03e2b2b45fef3 updated: a6ab3c254b...07ab87bc65
@@ -47,6 +47,32 @@ public class DatabaseOptions
|
|||||||
public string Provider { get; set; } = "Sqlite";
|
public string Provider { get; set; } = "Sqlite";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bitwarden Secrets Manager connection settings.
|
||||||
|
/// Stored in appsettings.json so they can bootstrap the connection before any other settings are loaded.
|
||||||
|
/// </summary>
|
||||||
|
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";
|
||||||
|
|
||||||
|
/// <summary>Machine account access token (sensitive — may be set via environment variable).</summary>
|
||||||
|
public string AccessToken { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string OrganizationId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Project where config secrets are created/listed. Required.</summary>
|
||||||
|
public string ProjectId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional separate project for instance-level secrets (DB passwords, Newt credentials, etc.).
|
||||||
|
/// When empty, instance secrets are stored in the default <see cref="ProjectId"/>.
|
||||||
|
/// </summary>
|
||||||
|
public string InstanceProjectId { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
public class InstanceDefaultsOptions
|
public class InstanceDefaultsOptions
|
||||||
{
|
{
|
||||||
public const string SectionName = "InstanceDefaults";
|
public const string SectionName = "InstanceDefaults";
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ public class XiboContext : DbContext
|
|||||||
|
|
||||||
public DbSet<SshHost> SshHosts => Set<SshHost>();
|
public DbSet<SshHost> SshHosts => Set<SshHost>();
|
||||||
public DbSet<OperationLog> OperationLogs => Set<OperationLog>();
|
public DbSet<OperationLog> OperationLogs => Set<OperationLog>();
|
||||||
public DbSet<AppSetting> AppSettings => Set<AppSetting>();
|
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
@@ -50,12 +49,5 @@ public class XiboContext : DbContext
|
|||||||
entity.HasIndex(e => e.StackName);
|
entity.HasIndex(e => e.StackName);
|
||||||
entity.HasIndex(e => e.Operation);
|
entity.HasIndex(e => e.Operation);
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- AppSetting ---
|
|
||||||
modelBuilder.Entity<AppSetting>(entity =>
|
|
||||||
{
|
|
||||||
entity.HasKey(e => e.Key);
|
|
||||||
entity.HasIndex(e => e.Category);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
125
OTSSignsOrchestrator.Core/Migrations/20260225135644_DropAppSettings.Designer.cs
generated
Normal file
125
OTSSignsOrchestrator.Core/Migrations/20260225135644_DropAppSettings.Designer.cs
generated
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
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
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long?>("DurationMs")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Message")
|
||||||
|
.HasMaxLength(2000)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("Operation")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("StackName")
|
||||||
|
.HasMaxLength(150)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<DateTime>("Timestamp")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Host")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("KeyPassphrase")
|
||||||
|
.HasMaxLength(2000)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Label")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool?>("LastTestSuccess")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("LastTestedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Password")
|
||||||
|
.HasMaxLength(2000)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("Port")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("PrivateKeyPath")
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("UseKeyAuth")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Username")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Label")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("SshHosts");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace OTSSignsOrchestrator.Core.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class DropAppSettings : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "AppSettings");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "AppSettings",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Key = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
|
||||||
|
Category = table.Column<string>(type: "TEXT", maxLength: 50, nullable: false),
|
||||||
|
IsSensitive = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||||
|
Value = table.Column<string>(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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,34 +17,6 @@ namespace OTSSignsOrchestrator.Core.Migrations
|
|||||||
#pragma warning disable 612, 618
|
#pragma warning disable 612, 618
|
||||||
modelBuilder.HasAnnotation("ProductVersion", "9.0.2");
|
modelBuilder.HasAnnotation("ProductVersion", "9.0.2");
|
||||||
|
|
||||||
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.AppSetting", b =>
|
|
||||||
{
|
|
||||||
b.Property<string>("Key")
|
|
||||||
.HasMaxLength(200)
|
|
||||||
.HasColumnType("TEXT");
|
|
||||||
|
|
||||||
b.Property<string>("Category")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(50)
|
|
||||||
.HasColumnType("TEXT");
|
|
||||||
|
|
||||||
b.Property<bool>("IsSensitive")
|
|
||||||
.HasColumnType("INTEGER");
|
|
||||||
|
|
||||||
b.Property<DateTime>("UpdatedAt")
|
|
||||||
.HasColumnType("TEXT");
|
|
||||||
|
|
||||||
b.Property<string>("Value")
|
|
||||||
.HasMaxLength(4000)
|
|
||||||
.HasColumnType("TEXT");
|
|
||||||
|
|
||||||
b.HasKey("Key");
|
|
||||||
|
|
||||||
b.HasIndex("Category");
|
|
||||||
|
|
||||||
b.ToTable("AppSettings");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.OperationLog", b =>
|
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.OperationLog", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
|
|||||||
@@ -37,4 +37,11 @@ public class CreateInstanceDto
|
|||||||
|
|
||||||
[MaxLength(500)]
|
[MaxLength(500)]
|
||||||
public string? NfsExtraOptions { get; set; }
|
public string? NfsExtraOptions { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public bool PurgeStaleVolumes { get; set; } = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,4 +10,10 @@ public class DeploymentResultDto
|
|||||||
public int ExitCode { get; set; }
|
public int ExitCode { get; set; }
|
||||||
public long DurationMs { get; set; }
|
public long DurationMs { get; set; }
|
||||||
public int ServiceCount { get; set; }
|
public int ServiceCount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The instance URL including the abbreviation sub-path (e.g. https://ots.ots-signs.com/ots).</summary>
|
||||||
|
public string? InstanceUrl { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The 3-letter abbreviation for this instance.</summary>
|
||||||
|
public string? Abbrev { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
24
OTSSignsOrchestrator.Core/Models/DTOs/ServiceLogEntry.cs
Normal file
24
OTSSignsOrchestrator.Core/Models/DTOs/ServiceLogEntry.cs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
namespace OTSSignsOrchestrator.Core.Models.DTOs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a single log line from a Docker service.
|
||||||
|
/// Parsed from <c>docker service logs --timestamps</c> output.
|
||||||
|
/// </summary>
|
||||||
|
public class ServiceLogEntry
|
||||||
|
{
|
||||||
|
/// <summary>UTC timestamp of the log entry (from Docker).</summary>
|
||||||
|
public DateTimeOffset Timestamp { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Service/replica identifier (e.g. "acm-cms-stack_acm-web.1.abc123").</summary>
|
||||||
|
public string Source { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>The log message text.</summary>
|
||||||
|
public string Message { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Short service name without the stack prefix (e.g. "acm-web").</summary>
|
||||||
|
public string ServiceName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Formatted display string for binding: "[timestamp] source | message".</summary>
|
||||||
|
public string DisplayLine =>
|
||||||
|
$"[{Timestamp:HH:mm:ss}] {Source} | {Message}";
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Bitwarden.Secrets.Sdk" Version="1.0.0" />
|
||||||
<PackageReference Include="LibGit2Sharp" Version="0.31.0" />
|
<PackageReference Include="LibGit2Sharp" Version="0.31.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="9.0.2" />
|
<PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="9.0.2" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.2" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.2" />
|
||||||
|
|||||||
@@ -1,37 +1,36 @@
|
|||||||
using System.Net.Http.Headers;
|
using Bitwarden.Sdk;
|
||||||
using System.Net.Http.Json;
|
|
||||||
using System.Text.Json;
|
|
||||||
using System.Text.Json.Serialization;
|
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using OTSSignsOrchestrator.Core.Configuration;
|
||||||
|
|
||||||
namespace OTSSignsOrchestrator.Core.Services;
|
namespace OTSSignsOrchestrator.Core.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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:
|
/// Configuration is read from <see cref="BitwardenOptions"/> (bound to appsettings.json → "Bitwarden").
|
||||||
/// Bitwarden.IdentityUrl – defaults to https://identity.bitwarden.com
|
///
|
||||||
/// Bitwarden.ApiUrl – defaults to https://api.bitwarden.com
|
/// The SDK state file is persisted to %APPDATA%/OTSSignsOrchestrator/bitwarden.state
|
||||||
/// Bitwarden.AccessToken – machine account access token (sensitive)
|
/// so the SDK can cache its internal state across restarts.
|
||||||
/// Bitwarden.OrganizationId – Bitwarden organisation that owns the project
|
|
||||||
/// Bitwarden.ProjectId – project where new secrets are created
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class BitwardenSecretService : IBitwardenSecretService
|
public class BitwardenSecretService : IBitwardenSecretService, IDisposable
|
||||||
{
|
{
|
||||||
private readonly IHttpClientFactory _http;
|
private readonly IOptionsMonitor<BitwardenOptions> _optionsMonitor;
|
||||||
private readonly SettingsService _settings;
|
|
||||||
private readonly ILogger<BitwardenSecretService> _logger;
|
private readonly ILogger<BitwardenSecretService> _logger;
|
||||||
|
|
||||||
// Cached bearer token (refreshed per service lifetime — transient registration is assumed)
|
// Lazily created on first use (per service instance — registered as Transient).
|
||||||
private string? _bearerToken;
|
private BitwardenClient? _client;
|
||||||
|
private string? _clientAccessToken; // track which token the client was created with
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
/// <summary>Always returns the latest config snapshot (reloaded when appsettings.json changes).</summary>
|
||||||
|
private BitwardenOptions Options => _optionsMonitor.CurrentValue;
|
||||||
|
|
||||||
public BitwardenSecretService(
|
public BitwardenSecretService(
|
||||||
IHttpClientFactory http,
|
IOptionsMonitor<BitwardenOptions> optionsMonitor,
|
||||||
SettingsService settings,
|
|
||||||
ILogger<BitwardenSecretService> logger)
|
ILogger<BitwardenSecretService> logger)
|
||||||
{
|
{
|
||||||
_http = http;
|
_optionsMonitor = optionsMonitor;
|
||||||
_settings = settings;
|
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,217 +38,193 @@ public class BitwardenSecretService : IBitwardenSecretService
|
|||||||
// IBitwardenSecretService
|
// IBitwardenSecretService
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
public async Task<bool> IsConfiguredAsync()
|
public Task<bool> IsConfiguredAsync()
|
||||||
{
|
{
|
||||||
var token = await _settings.GetAsync(SettingsService.BitwardenAccessToken);
|
var opts = Options;
|
||||||
var orgId = await _settings.GetAsync(SettingsService.BitwardenOrganizationId);
|
var configured = !string.IsNullOrWhiteSpace(opts.AccessToken)
|
||||||
return !string.IsNullOrWhiteSpace(token) && !string.IsNullOrWhiteSpace(orgId);
|
&& !string.IsNullOrWhiteSpace(opts.OrganizationId);
|
||||||
|
return Task.FromResult(configured);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string> CreateSecretAsync(string key, string value, string note = "")
|
public async Task<string> CreateSecretAsync(string key, string value, string note = "")
|
||||||
{
|
{
|
||||||
var bearer = await GetBearerTokenAsync();
|
var client = await GetClientAsync();
|
||||||
var projectId = await _settings.GetAsync(SettingsService.BitwardenProjectId);
|
var orgId = GetOrgId();
|
||||||
var orgId = await _settings.GetAsync(SettingsService.BitwardenOrganizationId)
|
var projectIds = GetProjectIds();
|
||||||
?? throw new InvalidOperationException("Bitwarden OrganizationId is not configured.");
|
|
||||||
var apiUrl = await GetApiUrlAsync();
|
|
||||||
|
|
||||||
var client = CreateClient(bearer);
|
var result = await Task.Run(() => client.Secrets.Create(orgId, key, value, note, projectIds));
|
||||||
var body = new
|
|
||||||
{
|
|
||||||
organizationId = orgId,
|
|
||||||
projectIds = projectId is not null ? new[] { projectId } : Array.Empty<string>(),
|
|
||||||
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<BwsSecretResponse>()
|
|
||||||
?? throw new InvalidOperationException("Empty response from Bitwarden API.");
|
|
||||||
|
|
||||||
_logger.LogInformation("Bitwarden secret created: key={Key}, id={Id}", key, result.Id);
|
_logger.LogInformation("Bitwarden secret created: key={Key}, id={Id}", key, result.Id);
|
||||||
return result.Id;
|
return result.Id.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> 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<BitwardenSecret> GetSecretAsync(string secretId)
|
public async Task<BitwardenSecret> GetSecretAsync(string secretId)
|
||||||
{
|
{
|
||||||
var bearer = await GetBearerTokenAsync();
|
var client = await GetClientAsync();
|
||||||
var apiUrl = await GetApiUrlAsync();
|
var result = await Task.Run(() => client.Secrets.Get(Guid.Parse(secretId)));
|
||||||
var client = CreateClient(bearer);
|
|
||||||
|
|
||||||
var response = await client.GetAsync($"{apiUrl}/secrets/{secretId}");
|
|
||||||
await EnsureSuccessAsync(response, "get secret");
|
|
||||||
|
|
||||||
var result = await response.Content.ReadFromJsonAsync<BwsSecretResponse>()
|
|
||||||
?? throw new InvalidOperationException("Empty response from Bitwarden API.");
|
|
||||||
|
|
||||||
return new BitwardenSecret
|
return new BitwardenSecret
|
||||||
{
|
{
|
||||||
Id = result.Id,
|
Id = result.Id.ToString(),
|
||||||
Key = result.Key,
|
Key = result.Key,
|
||||||
Value = result.Value,
|
Value = result.Value,
|
||||||
Note = result.Note ?? string.Empty,
|
Note = result.Note ?? string.Empty,
|
||||||
CreationDate = result.CreationDate
|
CreationDate = result.CreationDate.DateTime
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task UpdateSecretAsync(string secretId, string key, string value, string note = "")
|
public async Task UpdateSecretAsync(string secretId, string key, string value, string note = "")
|
||||||
{
|
{
|
||||||
var bearer = await GetBearerTokenAsync();
|
var client = await GetClientAsync();
|
||||||
var projectId = await _settings.GetAsync(SettingsService.BitwardenProjectId);
|
var orgId = GetOrgId();
|
||||||
var orgId = await _settings.GetAsync(SettingsService.BitwardenOrganizationId)
|
var projectIds = GetProjectIds();
|
||||||
?? throw new InvalidOperationException("Bitwarden OrganizationId is not configured.");
|
|
||||||
var apiUrl = await GetApiUrlAsync();
|
|
||||||
|
|
||||||
var client = CreateClient(bearer);
|
await Task.Run(() => client.Secrets.Update(orgId, Guid.Parse(secretId), key, value, note, projectIds));
|
||||||
var body = new
|
|
||||||
{
|
|
||||||
organizationId = orgId,
|
|
||||||
projectIds = projectId is not null ? new[] { projectId } : Array.Empty<string>(),
|
|
||||||
key = key,
|
|
||||||
value = value,
|
|
||||||
note = note
|
|
||||||
};
|
|
||||||
|
|
||||||
var response = await client.PutAsJsonAsync($"{apiUrl}/secrets/{secretId}", body);
|
|
||||||
await EnsureSuccessAsync(response, "update secret");
|
|
||||||
|
|
||||||
_logger.LogInformation("Bitwarden secret updated: key={Key}, id={Id}", key, secretId);
|
_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<List<BitwardenSecretSummary>> ListSecretsAsync()
|
public async Task<List<BitwardenSecretSummary>> ListSecretsAsync()
|
||||||
{
|
{
|
||||||
var bearer = await GetBearerTokenAsync();
|
var client = await GetClientAsync();
|
||||||
var orgId = await _settings.GetAsync(SettingsService.BitwardenOrganizationId)
|
var orgId = GetOrgId();
|
||||||
?? throw new InvalidOperationException("Bitwarden OrganizationId is not configured.");
|
var result = await Task.Run(() => client.Secrets.List(orgId));
|
||||||
var apiUrl = await GetApiUrlAsync();
|
|
||||||
var client = CreateClient(bearer);
|
|
||||||
|
|
||||||
var response = await client.GetAsync($"{apiUrl}/organizations/{orgId}/secrets");
|
return result.Data?.Select(s => new BitwardenSecretSummary
|
||||||
await EnsureSuccessAsync(response, "list secrets");
|
|
||||||
|
|
||||||
var result = await response.Content.ReadFromJsonAsync<BwsSecretsListResponse>();
|
|
||||||
|
|
||||||
return result?.Data?.Select(s => new BitwardenSecretSummary
|
|
||||||
{
|
{
|
||||||
Id = s.Id,
|
Id = s.Id.ToString(),
|
||||||
Key = s.Key,
|
Key = s.Key,
|
||||||
CreationDate = s.CreationDate
|
CreationDate = DateTime.MinValue
|
||||||
}).ToList() ?? new List<BitwardenSecretSummary>();
|
}).ToList() ?? new List<BitwardenSecretSummary>();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
// Auth
|
// SDK client initialisation
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Exchanges the machine-account access token for a short-lived Bearer token.
|
/// Returns an authenticated <see cref="BitwardenClient"/>, creating and logging in on first use.
|
||||||
/// The access token format is: 0.{tokenId}.{clientSecret}:{encKeyB64}
|
|
||||||
/// The client_id used is "machine.{tokenId}".
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task<string> GetBearerTokenAsync()
|
private async Task<BitwardenClient> GetClientAsync()
|
||||||
{
|
{
|
||||||
if (_bearerToken is not null)
|
var opts = Options;
|
||||||
return _bearerToken;
|
|
||||||
|
|
||||||
var rawToken = await _settings.GetAsync(SettingsService.BitwardenAccessToken)
|
// If credentials changed since the client was created, tear it down so we re-auth
|
||||||
?? throw new InvalidOperationException("Bitwarden AccessToken is not configured in Settings.");
|
if (_client is not null && _clientAccessToken != opts.AccessToken)
|
||||||
var identityUrl = await GetIdentityUrlAsync();
|
|
||||||
|
|
||||||
// Parse token: "0.<tokenId>.<clientSecretAndKey>" — 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.<tokenId>.<rest>");
|
|
||||||
|
|
||||||
var tokenId = parts[1];
|
|
||||||
var clientSecret = parts[2]; // may contain ":base64key" suffix — include all of it
|
|
||||||
|
|
||||||
var client = _http.CreateClient("Bitwarden");
|
|
||||||
var form = new FormUrlEncodedContent(new[]
|
|
||||||
{
|
{
|
||||||
new KeyValuePair<string, string>("grant_type", "client_credentials"),
|
_logger.LogInformation("Bitwarden credentials changed — recreating SDK client");
|
||||||
new KeyValuePair<string, string>("client_id", $"machine.{tokenId}"),
|
_client.Dispose();
|
||||||
new KeyValuePair<string, string>("client_secret", clientSecret),
|
_client = null;
|
||||||
new KeyValuePair<string, string>("scope", "api.secrets"),
|
}
|
||||||
});
|
|
||||||
|
|
||||||
var response = await client.PostAsync($"{identityUrl}/connect/token", form);
|
if (_client is not null)
|
||||||
await EnsureSuccessAsync(response, "authenticate with Bitwarden identity");
|
return _client;
|
||||||
|
|
||||||
var token = await response.Content.ReadFromJsonAsync<BwsTokenResponse>()
|
if (string.IsNullOrWhiteSpace(opts.AccessToken))
|
||||||
?? throw new InvalidOperationException("Empty token response from Bitwarden.");
|
throw new InvalidOperationException("Bitwarden AccessToken is not configured. Set it in Settings → Bitwarden.");
|
||||||
|
|
||||||
_bearerToken = token.AccessToken;
|
var accessToken = opts.AccessToken;
|
||||||
_logger.LogInformation("Bitwarden bearer token acquired.");
|
var apiUrl = (opts.ApiUrl ?? "https://api.bitwarden.com").TrimEnd('/');
|
||||||
return _bearerToken;
|
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
|
// Helpers
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private async Task<string> GetIdentityUrlAsync()
|
private Guid GetOrgId()
|
||||||
=> (await _settings.GetAsync(SettingsService.BitwardenIdentityUrl, "https://identity.bitwarden.com")).TrimEnd('/');
|
|
||||||
|
|
||||||
private async Task<string> GetApiUrlAsync()
|
|
||||||
=> (await _settings.GetAsync(SettingsService.BitwardenApiUrl, "https://api.bitwarden.com")).TrimEnd('/');
|
|
||||||
|
|
||||||
private static HttpClient CreateClient(string bearerToken)
|
|
||||||
{
|
{
|
||||||
var client = new HttpClient();
|
var orgId = Options.OrganizationId;
|
||||||
client.DefaultRequestHeaders.Authorization =
|
if (string.IsNullOrWhiteSpace(orgId))
|
||||||
new AuthenticationHeaderValue("Bearer", bearerToken);
|
throw new InvalidOperationException("Bitwarden OrganizationId is not configured.");
|
||||||
return client;
|
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))
|
||||||
var body = await response.Content.ReadAsStringAsync();
|
throw new InvalidOperationException(
|
||||||
throw new HttpRequestException(
|
"Bitwarden ProjectId is required. Set it in Settings → Bitwarden.");
|
||||||
$"Bitwarden API call '{operation}' failed: {(int)response.StatusCode} {response.ReasonPhrase} — {body}");
|
return new[] { Guid.Parse(projectId) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the project IDs array for instance-level secrets.
|
||||||
|
/// Uses <see cref="BitwardenOptions.InstanceProjectId"/> when configured,
|
||||||
|
/// otherwise falls back to the default <see cref="BitwardenOptions.ProjectId"/>.
|
||||||
|
/// </summary>
|
||||||
|
private Guid[] GetInstanceProjectIds()
|
||||||
|
{
|
||||||
|
var instanceProjectId = Options.InstanceProjectId;
|
||||||
|
if (!string.IsNullOrWhiteSpace(instanceProjectId))
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Using instance Bitwarden project: {ProjectId}", instanceProjectId);
|
||||||
|
return new[] { Guid.Parse(instanceProjectId) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to the default config project
|
||||||
|
return GetProjectIds();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the path where the SDK stores its state between sessions.
|
||||||
|
/// Stored under %APPDATA%/OTSSignsOrchestrator/bitwarden.state.
|
||||||
|
/// </summary>
|
||||||
|
private static string GetStateFilePath()
|
||||||
|
{
|
||||||
|
var dir = Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||||
|
"OTSSignsOrchestrator");
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
return Path.Combine(dir, "bitwarden.state");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
// Internal DTOs
|
// IDisposable
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private sealed class BwsTokenResponse
|
public void Dispose()
|
||||||
{
|
{
|
||||||
[JsonPropertyName("access_token")]
|
if (!_disposed)
|
||||||
public string AccessToken { get; set; } = string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class BwsSecretResponse
|
|
||||||
{
|
{
|
||||||
[JsonPropertyName("id")]
|
_client?.Dispose();
|
||||||
public string Id { get; set; } = string.Empty;
|
_disposed = true;
|
||||||
|
}
|
||||||
[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<BwsSecretResponse>? Data { get; set; }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,7 +111,10 @@ public class ComposeRenderService
|
|||||||
var export = (ctx.NfsExport ?? string.Empty).Trim('/');
|
var export = (ctx.NfsExport ?? string.Empty).Trim('/');
|
||||||
var folder = (ctx.NfsExportFolder ?? string.Empty).Trim('/');
|
var folder = (ctx.NfsExportFolder ?? string.Empty).Trim('/');
|
||||||
var path = string.IsNullOrEmpty(folder) ? export : $"{export}/{folder}";
|
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}";
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -195,6 +198,12 @@ public class ComposeRenderService
|
|||||||
- {{ABBREV}}-cms-ca-certs:/var/www/cms/ca-certs
|
- {{ABBREV}}-cms-ca-certs:/var/www/cms/ca-certs
|
||||||
ports:
|
ports:
|
||||||
- "{{HOST_HTTP_PORT}}:80"
|
- "{{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:
|
networks:
|
||||||
{{ABBREV}}-net:
|
{{ABBREV}}-net:
|
||||||
aliases:
|
aliases:
|
||||||
@@ -236,6 +245,9 @@ public class ComposeRenderService
|
|||||||
PANGOLIN_ENDPOINT: {{PANGOLIN_ENDPOINT}}
|
PANGOLIN_ENDPOINT: {{PANGOLIN_ENDPOINT}}
|
||||||
NEWT_ID: {{NEWT_ID}}
|
NEWT_ID: {{NEWT_ID}}
|
||||||
NEWT_SECRET: {{NEWT_SECRET}}
|
NEWT_SECRET: {{NEWT_SECRET}}
|
||||||
|
depends_on:
|
||||||
|
{{ABBREV}}-web:
|
||||||
|
condition: service_healthy
|
||||||
networks:
|
networks:
|
||||||
{{ABBREV}}-net: {}
|
{{ABBREV}}-net: {}
|
||||||
deploy:
|
deploy:
|
||||||
|
|||||||
@@ -16,6 +16,13 @@ public interface IBitwardenSecretService
|
|||||||
/// <returns>The ID of the created secret.</returns>
|
/// <returns>The ID of the created secret.</returns>
|
||||||
Task<string> CreateSecretAsync(string key, string value, string note = "");
|
Task<string> CreateSecretAsync(string key, string value, string note = "");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>The ID of the created secret.</returns>
|
||||||
|
Task<string> CreateInstanceSecretAsync(string key, string value, string note = "");
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Retrieves a secret by its Bitwarden ID.
|
/// Retrieves a secret by its Bitwarden ID.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -26,6 +33,11 @@ public interface IBitwardenSecretService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
Task UpdateSecretAsync(string secretId, string key, string value, string note = "");
|
Task UpdateSecretAsync(string secretId, string key, string value, string note = "");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates the value of an existing instance-level secret in place (uses instance project if configured).
|
||||||
|
/// </summary>
|
||||||
|
Task UpdateInstanceSecretAsync(string secretId, string key, string value, string note = "");
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Lists all secrets in the configured project.
|
/// Lists all secrets in the configured project.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ using OTSSignsOrchestrator.Core.Models.DTOs;
|
|||||||
|
|
||||||
namespace OTSSignsOrchestrator.Core.Services;
|
namespace OTSSignsOrchestrator.Core.Services;
|
||||||
|
|
||||||
|
// Re-export for convenience so consumers only need one using
|
||||||
|
using ServiceLogEntry = OTSSignsOrchestrator.Core.Models.DTOs.ServiceLogEntry;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Abstraction for Docker CLI stack operations (deploy, remove, list, inspect).
|
/// Abstraction for Docker CLI stack operations (deploy, remove, list, inspect).
|
||||||
/// Implementations may use local docker CLI or SSH-based remote execution.
|
/// Implementations may use local docker CLI or SSH-based remote execution.
|
||||||
@@ -87,6 +90,13 @@ public interface IDockerCliService
|
|||||||
/// <paramref name="oldSecretName"/> when null, keeping the same /run/secrets/ filename).
|
/// <paramref name="oldSecretName"/> when null, keeping the same /run/secrets/ filename).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<bool> ServiceSwapSecretAsync(string serviceName, string oldSecretName, string newSecretName, string? targetAlias = null);
|
Task<bool> ServiceSwapSecretAsync(string serviceName, string oldSecretName, string newSecretName, string? targetAlias = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fetches the last <paramref name="tailLines"/> log lines from a Docker Swarm service.
|
||||||
|
/// If <paramref name="serviceName"/> is null, fetches logs from all services in the stack.
|
||||||
|
/// Returns parsed log entries sorted by timestamp ascending.
|
||||||
|
/// </summary>
|
||||||
|
Task<List<ServiceLogEntry>> GetServiceLogsAsync(string stackName, string? serviceName = null, int tailLines = 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class StackInfo
|
public class StackInfo
|
||||||
|
|||||||
@@ -99,10 +99,22 @@ public class InstanceService
|
|||||||
_logger.LogInformation("Fetching template repo: {RepoUrl}", repoUrl);
|
_logger.LogInformation("Fetching template repo: {RepoUrl}", repoUrl);
|
||||||
var templateConfig = await _git.FetchAsync(repoUrl, repoPat);
|
var templateConfig = await _git.FetchAsync(repoUrl, repoPat);
|
||||||
|
|
||||||
// ── 1b. Remove any stale stack that might hold references to old secrets ─
|
// ── 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);
|
_logger.LogInformation("Removing stale stack (if any): {StackName}", stackName);
|
||||||
await _docker.RemoveStackAsync(stackName);
|
await _docker.RemoveStackAsync(stackName);
|
||||||
await Task.Delay(2000);
|
await Task.Delay(2000);
|
||||||
|
}
|
||||||
|
|
||||||
// ── 2. Generate MySQL credentials ──────────────────────────────
|
// ── 2. Generate MySQL credentials ──────────────────────────────
|
||||||
var mysqlPassword = GenerateRandomPassword(32);
|
var mysqlPassword = GenerateRandomPassword(32);
|
||||||
@@ -129,12 +141,11 @@ public class InstanceService
|
|||||||
throw new InvalidOperationException($"MySQL database/user setup failed: {mysqlMsg}");
|
throw new InvalidOperationException($"MySQL database/user setup failed: {mysqlMsg}");
|
||||||
_logger.LogInformation("MySQL setup complete: {Message}", mysqlMsg);
|
_logger.LogInformation("MySQL setup complete: {Message}", mysqlMsg);
|
||||||
|
|
||||||
// ── 2c. Persist password (encrypted) for future redeploys ────────
|
// ── 2c. Persist password for future redeploys ────────
|
||||||
await _settings.SetAsync(
|
await _settings.SetAsync(
|
||||||
SettingsService.InstanceMySqlPassword(abbrev), mysqlPassword,
|
SettingsService.InstanceMySqlPassword(abbrev), mysqlPassword,
|
||||||
SettingsService.CatInstance, isSensitive: true);
|
SettingsService.CatInstance, isSensitive: true);
|
||||||
await _db.SaveChangesAsync();
|
_logger.LogInformation("MySQL password stored in Bitwarden for instance {Abbrev}", abbrev);
|
||||||
_logger.LogInformation("MySQL password stored in settings for instance {Abbrev}", abbrev);
|
|
||||||
|
|
||||||
// ── 3. Read settings ────────────────────────────────────────────
|
// ── 3. Read settings ────────────────────────────────────────────
|
||||||
var mySqlHost = mySqlHostValue;
|
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.");
|
+ "(2) NFS export has root_squash enabled — set 'No mapping' / no_root_squash on the NFS server.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 6. Remove stale NFS volumes ─────────────────────────────────
|
// ── 6. Deploy stack ─────────────────────────────────────────────
|
||||||
_logger.LogInformation("Removing stale NFS volumes for stack {StackName}", stackName);
|
|
||||||
await _docker.RemoveStackVolumesAsync(stackName);
|
|
||||||
|
|
||||||
// ── 7. Deploy stack ─────────────────────────────────────────────
|
|
||||||
var deployResult = await _docker.DeployStackAsync(stackName, composeYaml);
|
var deployResult = await _docker.DeployStackAsync(stackName, composeYaml);
|
||||||
if (!deployResult.Success)
|
if (!deployResult.Success)
|
||||||
throw new InvalidOperationException($"Stack deployment failed: {deployResult.ErrorMessage}");
|
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);
|
_logger.LogInformation("Instance created: {StackName} | duration={DurationMs}ms", stackName, sw.ElapsedMilliseconds);
|
||||||
|
|
||||||
// ── 8. Post-instance init (fire-and-forget background task) ──────
|
// ── 7. Return result — post-init will be triggered by the UI ──────
|
||||||
// Waits for Xibo to be ready then creates admin user, OAuth app, and sets theme.
|
// after the user creates an OAuth2 app in the Xibo web UI and supplies
|
||||||
var instanceUrl = $"https://{cmsServerName}";
|
// the client_id and client_secret.
|
||||||
_ = Task.Run(async () => await _postInit.RunAsync(abbrev, instanceUrl));
|
var instanceUrl = $"https://{cmsServerName}/{abbrev}";
|
||||||
|
|
||||||
|
deployResult.InstanceUrl = instanceUrl;
|
||||||
|
deployResult.Abbrev = abbrev;
|
||||||
deployResult.ServiceCount = 4;
|
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;
|
return deployResult;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using System.Text.RegularExpressions;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using OTSSignsOrchestrator.Core.Data;
|
|
||||||
|
|
||||||
namespace OTSSignsOrchestrator.Core.Services;
|
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:
|
/// Runs once after a Xibo CMS stack is deployed to complete post-install setup:
|
||||||
/// <list type="number">
|
/// <list type="number">
|
||||||
/// <item>Waits for the Xibo web service to become available.</item>
|
/// <item>Waits for the Xibo web service to become available.</item>
|
||||||
|
/// <item>Authenticates using the OAuth2 application credentials supplied by the user.</item>
|
||||||
/// <item>Creates the OTS admin user with a random password.</item>
|
/// <item>Creates the OTS admin user with a random password.</item>
|
||||||
/// <item>Registers a dedicated client_credentials OAuth2 application for OTS.</item>
|
/// <item>Registers a dedicated client_credentials OAuth2 application for OTS.</item>
|
||||||
/// <item>Activates the <c>otssigns</c> theme.</item>
|
/// <item>Activates the <c>otssigns</c> theme.</item>
|
||||||
/// <item>Stores all generated credentials in Bitwarden Secrets Manager.</item>
|
/// <item>Stores all generated credentials in Bitwarden Secrets Manager.</item>
|
||||||
|
/// <item>Deletes the default <c>xibo_admin</c> account.</item>
|
||||||
/// </list>
|
/// </list>
|
||||||
///
|
///
|
||||||
/// Invoked as a background fire-and-forget task from <see cref="InstanceService.CreateInstanceAsync"/>.
|
/// The user must first create an OAuth2 application (client_credentials) in the
|
||||||
|
/// Xibo web UI using the default <c>xibo_admin / password</c> account that ships
|
||||||
|
/// with every new Xibo CMS instance.
|
||||||
|
///
|
||||||
|
/// Invoked from the Create Instance UI after the user supplies the OAuth credentials.
|
||||||
/// Progress is tracked in the operation log; failures are logged but do not roll back the deployment.
|
/// Progress is tracked in the operation log; failures are logged but do not roll back the deployment.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// </summary>
|
||||||
public class PostInstanceInitService
|
public class PostInstanceInitService
|
||||||
{
|
{
|
||||||
private readonly IServiceProvider _services;
|
private readonly IServiceProvider _services;
|
||||||
@@ -41,9 +47,14 @@ public class PostInstanceInitService
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Executes the post-instance initialisation sequence.
|
/// Executes the post-instance initialisation sequence.
|
||||||
/// Intended to be called as a background task after stack deployment succeeds.
|
/// Called from the UI after the user supplies OAuth2 client credentials.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task RunAsync(string abbrev, string instanceUrl, CancellationToken ct = default)
|
public async Task RunAsync(
|
||||||
|
string abbrev,
|
||||||
|
string instanceUrl,
|
||||||
|
string clientId,
|
||||||
|
string clientSecret,
|
||||||
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("[PostInit] Starting post-instance init for {Abbrev} ({Url})", abbrev, instanceUrl);
|
_logger.LogInformation("[PostInit] Starting post-instance init for {Abbrev} ({Url})", abbrev, instanceUrl);
|
||||||
|
|
||||||
@@ -53,24 +64,6 @@ public class PostInstanceInitService
|
|||||||
var xibo = scope.ServiceProvider.GetRequiredService<XiboApiService>();
|
var xibo = scope.ServiceProvider.GetRequiredService<XiboApiService>();
|
||||||
var bws = scope.ServiceProvider.GetRequiredService<IBitwardenSecretService>();
|
var bws = scope.ServiceProvider.GetRequiredService<IBitwardenSecretService>();
|
||||||
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
|
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<XiboContext>();
|
|
||||||
|
|
||||||
// ── Validate Bitwarden is configured ────────────────────────────
|
|
||||||
if (!await bws.IsConfiguredAsync())
|
|
||||||
{
|
|
||||||
_logger.LogWarning(
|
|
||||||
"[PostInit] Bitwarden is not configured — credentials will NOT be stored in Bitwarden. " +
|
|
||||||
"Configure Settings → Bitwarden to enable secret storage.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Read bootstrap credentials ───────────────────────────────────
|
|
||||||
var bootstrapClientId = await settings.GetAsync(SettingsService.XiboBootstrapClientId);
|
|
||||||
var bootstrapClientSecret = await settings.GetAsync(SettingsService.XiboBootstrapClientSecret);
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(bootstrapClientId) || string.IsNullOrWhiteSpace(bootstrapClientSecret))
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
"Xibo bootstrap OAuth2 credentials are not configured. " +
|
|
||||||
"Set Settings → Xibo Bootstrap Client ID / Secret to enable post-instance setup.");
|
|
||||||
|
|
||||||
// ── 1. Wait for Xibo readiness ────────────────────────────────────
|
// ── 1. Wait for Xibo readiness ────────────────────────────────────
|
||||||
_logger.LogInformation("[PostInit] Waiting for Xibo to become ready at {Url}...", instanceUrl);
|
_logger.LogInformation("[PostInit] Waiting for Xibo to become ready at {Url}...", instanceUrl);
|
||||||
@@ -79,74 +72,158 @@ public class PostInstanceInitService
|
|||||||
throw new TimeoutException(
|
throw new TimeoutException(
|
||||||
$"Xibo instance at {instanceUrl} did not become ready within {XiboReadinessTimeout.TotalMinutes} minutes.");
|
$"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 adminUsername = $"ots-admin-{abbrev}";
|
||||||
var adminPassword = GeneratePassword(24);
|
var adminPassword = GeneratePassword(24);
|
||||||
var adminEmail = $"ots-admin-{abbrev}@ots-signs.com";
|
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);
|
_logger.LogInformation("[PostInit] Creating OTS admin user '{Username}'", adminUsername);
|
||||||
int userId = await xibo.CreateAdminUserAsync(
|
int userId = await xibo.CreateAdminUserAsync(
|
||||||
instanceUrl, bootstrapClientId, bootstrapClientSecret,
|
instanceUrl, accessToken,
|
||||||
adminUsername, adminPassword, adminEmail);
|
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");
|
_logger.LogInformation("[PostInit] Registering OTS OAuth2 application");
|
||||||
var (oauthClientId, oauthClientSecret) = await xibo.RegisterOAuthClientAsync(
|
var (otsClientId, otsClientSecret) = await xibo.RegisterOAuthClientAsync(
|
||||||
instanceUrl, bootstrapClientId, bootstrapClientSecret,
|
instanceUrl, accessToken,
|
||||||
appName: $"OTS Signs — {abbrev.ToUpperInvariant()}");
|
$"OTS Signs — {abbrev.ToUpperInvariant()}");
|
||||||
|
|
||||||
// ── 5. Set theme ──────────────────────────────────────────────────
|
// ── 6. Set theme ──────────────────────────────────────────────────
|
||||||
_logger.LogInformation("[PostInit] Setting Xibo theme to 'otssigns'");
|
_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 ─────────────────────────────
|
// ── 7. Store credentials in Bitwarden ─────────────────────────────
|
||||||
if (await bws.IsConfiguredAsync())
|
|
||||||
{
|
|
||||||
_logger.LogInformation("[PostInit] Storing credentials in Bitwarden");
|
_logger.LogInformation("[PostInit] Storing credentials in Bitwarden");
|
||||||
|
|
||||||
var adminSecretId = await bws.CreateSecretAsync(
|
var adminSecretId = await bws.CreateInstanceSecretAsync(
|
||||||
key: $"{abbrev}/xibo-admin-password",
|
key: $"{abbrev}/xibo-admin-password",
|
||||||
value: adminPassword,
|
value: adminPassword,
|
||||||
note: $"Xibo CMS admin password for instance {abbrev}. Username: {adminUsername}");
|
note: $"Xibo CMS admin password for instance {abbrev}. Username: {adminUsername}");
|
||||||
|
|
||||||
var oauthSecretId = await bws.CreateSecretAsync(
|
var oauthSecretId = await bws.CreateInstanceSecretAsync(
|
||||||
key: $"{abbrev}/xibo-oauth-secret",
|
key: $"{abbrev}/xibo-oauth-secret",
|
||||||
value: oauthClientSecret,
|
value: otsClientSecret,
|
||||||
note: $"Xibo CMS OAuth2 client secret for OTS application. ClientId: {oauthClientId}");
|
note: $"Xibo CMS OAuth2 client secret for OTS application. ClientId: {otsClientId}");
|
||||||
|
|
||||||
// Persist Bitwarden secret IDs + OAuth client ID in AppSettings for later retrieval
|
// Persist Bitwarden secret IDs + OAuth client ID as config settings
|
||||||
await settings.SetAsync(SettingsService.InstanceAdminPasswordSecretId(abbrev), adminSecretId,
|
await settings.SetAsync(SettingsService.InstanceAdminPasswordSecretId(abbrev), adminSecretId,
|
||||||
SettingsService.CatInstance, isSensitive: false);
|
SettingsService.CatInstance, isSensitive: false);
|
||||||
await settings.SetAsync(SettingsService.InstanceOAuthSecretId(abbrev), oauthSecretId,
|
await settings.SetAsync(SettingsService.InstanceOAuthSecretId(abbrev), oauthSecretId,
|
||||||
SettingsService.CatInstance, isSensitive: false);
|
SettingsService.CatInstance, isSensitive: false);
|
||||||
await settings.SetAsync(SettingsService.InstanceOAuthClientId(abbrev), oauthClientId,
|
await settings.SetAsync(SettingsService.InstanceOAuthClientId(abbrev), otsClientId,
|
||||||
SettingsService.CatInstance, isSensitive: false);
|
SettingsService.CatInstance, isSensitive: false);
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// No Bitwarden — fall back to encrypted AppSettings (less ideal for secrets)
|
|
||||||
_logger.LogWarning(
|
|
||||||
"[PostInit] Bitwarden not configured. Storing admin password locally (encrypted). " +
|
|
||||||
"OAuth client secret is discarded — re-register from the instance details modal.");
|
|
||||||
|
|
||||||
await settings.SetAsync(
|
// ── 8. Remove the default xibo_admin account ──────────────────────
|
||||||
$"Instance.{abbrev}.AdminPassword", adminPassword,
|
_logger.LogInformation("[PostInit] Removing default xibo_admin user");
|
||||||
SettingsService.CatInstance, isSensitive: true);
|
var xiboAdminId = await xibo.GetUserIdByNameAsync(instanceUrl, accessToken, "xibo_admin");
|
||||||
await settings.SetAsync(SettingsService.InstanceOAuthClientId(abbrev), oauthClientId,
|
await xibo.DeleteUserAsync(instanceUrl, accessToken, xiboAdminId);
|
||||||
SettingsService.CatInstance, isSensitive: false);
|
_logger.LogInformation("[PostInit] xibo_admin user removed (userId={UserId})", xiboAdminId);
|
||||||
}
|
|
||||||
|
|
||||||
await db.SaveChangesAsync(ct);
|
|
||||||
|
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"[PostInit] Post-instance init complete for {Abbrev}. Admin user: {Username}, OAuth ClientId: {ClientId}",
|
"[PostInit] Post-instance init complete for {Abbrev}. Admin user: {Username}, OAuth ClientId: {ClientId}",
|
||||||
abbrev, adminUsername, oauthClientId);
|
abbrev, adminUsername, otsClientId);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "[PostInit] Post-instance init failed for {Abbrev}: {Message}", abbrev, ex.Message);
|
_logger.LogError(ex, "[PostInit] Post-instance init failed for {Abbrev}: {Message}", abbrev, ex.Message);
|
||||||
// Do NOT rethrow — the stack is already deployed; we don't want to break the deployment result.
|
throw; // Propagate to calling UI so the user sees the error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Initialise using caller-supplied OAuth credentials (no new app registration)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initialises a Xibo CMS instance using OAuth2 credentials supplied by the user.
|
||||||
|
/// Unlike <see cref="RunAsync"/>, this method does NOT register a new OAuth application;
|
||||||
|
/// instead it stores the caller-supplied credentials for future API operations.
|
||||||
|
/// Steps: wait → authenticate → create OTS admin → set theme → remove xibo_admin → store credentials.
|
||||||
|
/// </summary>
|
||||||
|
public async Task InitializeWithOAuthAsync(
|
||||||
|
string abbrev,
|
||||||
|
string instanceUrl,
|
||||||
|
string clientId,
|
||||||
|
string clientSecret,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("[PostInit] Starting initialisation for {Abbrev} ({Url})", abbrev, instanceUrl);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var scope = _services.CreateScope();
|
||||||
|
var xibo = scope.ServiceProvider.GetRequiredService<XiboApiService>();
|
||||||
|
var bws = scope.ServiceProvider.GetRequiredService<IBitwardenSecretService>();
|
||||||
|
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
|
||||||
|
|
||||||
|
// ── 1. Wait for Xibo readiness ────────────────────────────────────
|
||||||
|
_logger.LogInformation("[PostInit] Waiting for Xibo at {Url}...", instanceUrl);
|
||||||
|
var ready = await xibo.WaitForReadyAsync(instanceUrl, XiboReadinessTimeout, ct);
|
||||||
|
if (!ready)
|
||||||
|
throw new TimeoutException(
|
||||||
|
$"Xibo at {instanceUrl} did not become ready within {XiboReadinessTimeout.TotalMinutes} minutes.");
|
||||||
|
|
||||||
|
// ── 2. Authenticate with caller-supplied OAuth2 credentials ───────
|
||||||
|
_logger.LogInformation("[PostInit] Obtaining access token");
|
||||||
|
var accessToken = await xibo.LoginAsync(instanceUrl, clientId, clientSecret);
|
||||||
|
|
||||||
|
// ── 3. Generate OTS admin credentials ─────────────────────────────
|
||||||
|
var adminUsername = $"ots-admin-{abbrev}";
|
||||||
|
var adminPassword = GeneratePassword(24);
|
||||||
|
var adminEmail = $"ots-admin-{abbrev}@ots-signs.com";
|
||||||
|
|
||||||
|
// ── 4. Rename built-in xibo_admin to OTS admin ───────────────────
|
||||||
|
_logger.LogInformation("[PostInit] Looking up xibo_admin user");
|
||||||
|
var xiboAdminId = await xibo.GetUserIdByNameAsync(instanceUrl, accessToken, "xibo_admin");
|
||||||
|
_logger.LogInformation("[PostInit] Updating xibo_admin (id={Id}) → '{Username}'", xiboAdminId, adminUsername);
|
||||||
|
await xibo.UpdateUserAsync(instanceUrl, accessToken, xiboAdminId, adminUsername, adminPassword, adminEmail);
|
||||||
|
|
||||||
|
// ── 5. Set theme ──────────────────────────────────────────────────
|
||||||
|
_logger.LogInformation("[PostInit] Setting theme to 'otssigns'");
|
||||||
|
await xibo.SetThemeAsync(instanceUrl, accessToken, "otssigns");
|
||||||
|
|
||||||
|
// ── 6. Store admin password in Bitwarden ──────────────────────────
|
||||||
|
_logger.LogInformation("[PostInit] Storing credentials in Bitwarden");
|
||||||
|
var adminSecretId = await bws.CreateInstanceSecretAsync(
|
||||||
|
key: $"{abbrev}/xibo-admin-password",
|
||||||
|
value: adminPassword,
|
||||||
|
note: $"Xibo CMS admin password for instance {abbrev}. Username: {adminUsername}");
|
||||||
|
|
||||||
|
await settings.SetAsync(SettingsService.InstanceAdminPasswordSecretId(abbrev), adminSecretId,
|
||||||
|
SettingsService.CatInstance, isSensitive: false);
|
||||||
|
|
||||||
|
// ── 7. Store caller-supplied OAuth credentials in Bitwarden ───────
|
||||||
|
var oauthSecretId = await bws.CreateInstanceSecretAsync(
|
||||||
|
key: $"{abbrev}/xibo-oauth-secret",
|
||||||
|
value: clientSecret,
|
||||||
|
note: $"Xibo CMS OAuth2 client secret for instance {abbrev}. ClientId: {clientId}");
|
||||||
|
|
||||||
|
await settings.SetAsync(SettingsService.InstanceOAuthSecretId(abbrev), oauthSecretId,
|
||||||
|
SettingsService.CatInstance, isSensitive: false);
|
||||||
|
await settings.SetAsync(SettingsService.InstanceOAuthClientId(abbrev), clientId,
|
||||||
|
SettingsService.CatInstance, isSensitive: false);
|
||||||
|
_logger.LogInformation("[PostInit] xibo_admin removed");
|
||||||
|
|
||||||
|
_logger.LogInformation("[PostInit] Initialisation complete for {Abbrev}", abbrev);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "[PostInit] Initialisation failed for {Abbrev}: {Message}", abbrev, ex.Message);
|
||||||
|
throw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,31 +242,30 @@ public class PostInstanceInitService
|
|||||||
var xibo = scope.ServiceProvider.GetRequiredService<XiboApiService>();
|
var xibo = scope.ServiceProvider.GetRequiredService<XiboApiService>();
|
||||||
var bws = scope.ServiceProvider.GetRequiredService<IBitwardenSecretService>();
|
var bws = scope.ServiceProvider.GetRequiredService<IBitwardenSecretService>();
|
||||||
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
|
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<XiboContext>();
|
|
||||||
|
|
||||||
// Get bootstrap credentials to authenticate against Xibo API
|
|
||||||
var bootstrapClientId = await settings.GetAsync(SettingsService.XiboBootstrapClientId)
|
|
||||||
?? throw new InvalidOperationException("Xibo bootstrap client not configured.");
|
|
||||||
var bootstrapClientSecret = await settings.GetAsync(SettingsService.XiboBootstrapClientSecret)
|
|
||||||
?? throw new InvalidOperationException("Xibo bootstrap client secret not configured.");
|
|
||||||
|
|
||||||
// Xibo user ID: we store it as the numeric userId in the username convention
|
|
||||||
// We need to look it up from the admin username. For now derive from OTS convention.
|
|
||||||
var adminUsername = $"ots-admin-{abbrev}";
|
var adminUsername = $"ots-admin-{abbrev}";
|
||||||
var newPassword = GeneratePassword(24);
|
var newPassword = GeneratePassword(24);
|
||||||
|
|
||||||
// We need to look up the userId — use the Xibo API (list users, find by userName)
|
// Log in using the stored OTS OAuth2 client credentials
|
||||||
var userId = await GetXiboUserIdAsync(xibo, instanceUrl, bootstrapClientId, bootstrapClientSecret, adminUsername);
|
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
|
// Look up the OTS admin user ID
|
||||||
if (await bws.IsConfiguredAsync())
|
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));
|
var secretId = await settings.GetAsync(SettingsService.InstanceAdminPasswordSecretId(abbrev));
|
||||||
if (!string.IsNullOrWhiteSpace(secretId))
|
if (!string.IsNullOrWhiteSpace(secretId))
|
||||||
{
|
{
|
||||||
await bws.UpdateSecretAsync(secretId,
|
await bws.UpdateInstanceSecretAsync(secretId,
|
||||||
key: $"{abbrev}/xibo-admin-password",
|
key: $"{abbrev}/xibo-admin-password",
|
||||||
value: newPassword,
|
value: newPassword,
|
||||||
note: $"Rotated on {DateTime.UtcNow:yyyy-MM-dd HH:mm} UTC. Username: {adminUsername}");
|
note: $"Rotated on {DateTime.UtcNow:yyyy-MM-dd HH:mm} UTC. Username: {adminUsername}");
|
||||||
@@ -197,27 +273,19 @@ public class PostInstanceInitService
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Secret doesn't exist yet in Bitwarden — create it now
|
// Secret doesn't exist yet in Bitwarden — create it now
|
||||||
var newSecretId = await bws.CreateSecretAsync(
|
var newSecretId = await bws.CreateInstanceSecretAsync(
|
||||||
$"{abbrev}/xibo-admin-password", newPassword,
|
$"{abbrev}/xibo-admin-password", newPassword,
|
||||||
$"Rotated on {DateTime.UtcNow:yyyy-MM-dd HH:mm} UTC. Username: {adminUsername}");
|
$"Rotated on {DateTime.UtcNow:yyyy-MM-dd HH:mm} UTC. Username: {adminUsername}");
|
||||||
await settings.SetAsync(SettingsService.InstanceAdminPasswordSecretId(abbrev), newSecretId,
|
await settings.SetAsync(SettingsService.InstanceAdminPasswordSecretId(abbrev), newSecretId,
|
||||||
SettingsService.CatInstance, isSensitive: false);
|
SettingsService.CatInstance, isSensitive: false);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Fallback: encrypted AppSettings
|
|
||||||
await settings.SetAsync($"Instance.{abbrev}.AdminPassword", newPassword,
|
|
||||||
SettingsService.CatInstance, isSensitive: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.SaveChangesAsync();
|
|
||||||
_logger.LogInformation("[PostInit] Admin password rotated for {Abbrev}", abbrev);
|
_logger.LogInformation("[PostInit] Admin password rotated for {Abbrev}", abbrev);
|
||||||
return newPassword;
|
return newPassword;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the stored admin password for an instance (from Bitwarden or local AppSettings).
|
/// Returns the stored admin password for an instance from Bitwarden.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<InstanceCredentials> GetCredentialsAsync(string abbrev)
|
public async Task<InstanceCredentials> GetCredentialsAsync(string abbrev)
|
||||||
{
|
{
|
||||||
@@ -230,8 +298,6 @@ public class PostInstanceInitService
|
|||||||
string? adminPassword = null;
|
string? adminPassword = null;
|
||||||
string? oauthClientSecret = null;
|
string? oauthClientSecret = null;
|
||||||
|
|
||||||
if (await bws.IsConfiguredAsync())
|
|
||||||
{
|
|
||||||
var adminSecretId = await settings.GetAsync(SettingsService.InstanceAdminPasswordSecretId(abbrev));
|
var adminSecretId = await settings.GetAsync(SettingsService.InstanceAdminPasswordSecretId(abbrev));
|
||||||
if (!string.IsNullOrWhiteSpace(adminSecretId))
|
if (!string.IsNullOrWhiteSpace(adminSecretId))
|
||||||
{
|
{
|
||||||
@@ -259,11 +325,6 @@ public class PostInstanceInitService
|
|||||||
_logger.LogWarning(ex, "Could not retrieve OAuth secret from Bitwarden for {Abbrev}", abbrev);
|
_logger.LogWarning(ex, "Could not retrieve OAuth secret from Bitwarden for {Abbrev}", abbrev);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
adminPassword = await settings.GetAsync($"Instance.{abbrev}.AdminPassword");
|
|
||||||
}
|
|
||||||
|
|
||||||
return new InstanceCredentials
|
return new InstanceCredentials
|
||||||
{
|
{
|
||||||
@@ -284,50 +345,109 @@ public class PostInstanceInitService
|
|||||||
return RandomNumberGenerator.GetString(chars, length);
|
return RandomNumberGenerator.GetString(chars, length);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<int> GetXiboUserIdAsync(
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
XiboApiService xibo,
|
// Import existing instance secrets on startup
|
||||||
string instanceUrl,
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
string clientId,
|
|
||||||
string clientSecret,
|
/// <summary>
|
||||||
string targetUsername)
|
/// Scans all Bitwarden secrets for existing instance-level credentials
|
||||||
|
/// (matching the <c>{abbrev}/xibo-admin-password</c> and <c>{abbrev}/xibo-oauth-secret</c>
|
||||||
|
/// naming convention) and imports their mappings into the config settings so
|
||||||
|
/// the app knows about them without a manual re-provisioning step.
|
||||||
|
/// Safe to call on every startup — existing mappings are never overwritten.
|
||||||
|
/// </summary>
|
||||||
|
public async Task ImportExistingInstanceSecretsAsync()
|
||||||
{
|
{
|
||||||
// This is a lightweight HTTP GET to fetch the user list and find by name.
|
try
|
||||||
// We use HttpClient directly since XiboApiService doesn't expose this as a standalone call.
|
|
||||||
using var http = new System.Net.Http.HttpClient();
|
|
||||||
var baseUrl = instanceUrl.TrimEnd('/');
|
|
||||||
|
|
||||||
// Get token first via the TestConnectionAsync pattern
|
|
||||||
var form = new FormUrlEncodedContent(new[]
|
|
||||||
{
|
{
|
||||||
new KeyValuePair<string, string>("grant_type", "client_credentials"),
|
using var scope = _services.CreateScope();
|
||||||
new KeyValuePair<string, string>("client_id", clientId),
|
var bws = scope.ServiceProvider.GetRequiredService<IBitwardenSecretService>();
|
||||||
new KeyValuePair<string, string>("client_secret", clientSecret),
|
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
|
||||||
});
|
|
||||||
|
|
||||||
var tokenResp = await http.PostAsync($"{baseUrl}/api/authorize/access_token", form);
|
if (!await bws.IsConfiguredAsync())
|
||||||
tokenResp.EnsureSuccessStatusCode();
|
|
||||||
|
|
||||||
using var tokenDoc = await System.Text.Json.JsonDocument.ParseAsync(
|
|
||||||
await tokenResp.Content.ReadAsStreamAsync());
|
|
||||||
var token = tokenDoc.RootElement.GetProperty("access_token").GetString()!;
|
|
||||||
|
|
||||||
http.DefaultRequestHeaders.Authorization =
|
|
||||||
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
|
|
||||||
|
|
||||||
var usersResp = await http.GetAsync($"{baseUrl}/api/user?userName={Uri.EscapeDataString(targetUsername)}");
|
|
||||||
usersResp.EnsureSuccessStatusCode();
|
|
||||||
|
|
||||||
using var usersDoc = await System.Text.Json.JsonDocument.ParseAsync(
|
|
||||||
await usersResp.Content.ReadAsStreamAsync());
|
|
||||||
foreach (var user in usersDoc.RootElement.EnumerateArray())
|
|
||||||
{
|
{
|
||||||
var name = user.GetProperty("userName").GetString();
|
_logger.LogDebug("[Import] Bitwarden not configured — skipping instance secret import");
|
||||||
if (string.Equals(name, targetUsername, StringComparison.OrdinalIgnoreCase))
|
return;
|
||||||
return user.GetProperty("userId").GetInt32();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new InvalidOperationException(
|
var allSecrets = await bws.ListSecretsAsync();
|
||||||
$"Xibo user '{targetUsername}' not found. Post-instance init may not have completed yet.");
|
var imported = 0;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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);
|
||||||
|
|
||||||
|
// 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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,20 @@
|
|||||||
using Microsoft.AspNetCore.DataProtection;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using OTSSignsOrchestrator.Core.Data;
|
|
||||||
using OTSSignsOrchestrator.Core.Models.Entities;
|
|
||||||
|
|
||||||
namespace OTSSignsOrchestrator.Core.Services;
|
namespace OTSSignsOrchestrator.Core.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Reads and writes typed application settings from the AppSetting table.
|
/// Reads and writes application settings from Bitwarden Secrets Manager.
|
||||||
/// Sensitive values are encrypted/decrypted transparently via DataProtection.
|
/// Each setting is stored as a Bitwarden secret with key prefix "ots-config/".
|
||||||
|
/// The secret's Note field stores metadata (category|isSensitive).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class SettingsService
|
public class SettingsService
|
||||||
{
|
{
|
||||||
private readonly XiboContext _db;
|
private readonly IBitwardenSecretService _bws;
|
||||||
private readonly IDataProtector _protector;
|
|
||||||
private readonly ILogger<SettingsService> _logger;
|
private readonly ILogger<SettingsService> _logger;
|
||||||
|
|
||||||
|
/// <summary>Prefix applied to all config secret keys in Bitwarden.</summary>
|
||||||
|
private const string KeyPrefix = "ots-config/";
|
||||||
|
|
||||||
// ── Category constants ─────────────────────────────────────────────────
|
// ── Category constants ─────────────────────────────────────────────────
|
||||||
public const string CatGit = "Git";
|
public const string CatGit = "Git";
|
||||||
public const string CatMySql = "MySql";
|
public const string CatMySql = "MySql";
|
||||||
@@ -68,14 +67,6 @@ public class SettingsService
|
|||||||
public const string DefaultPhpUploadMaxFilesize = "Defaults.PhpUploadMaxFilesize";
|
public const string DefaultPhpUploadMaxFilesize = "Defaults.PhpUploadMaxFilesize";
|
||||||
public const string DefaultPhpMaxExecutionTime = "Defaults.PhpMaxExecutionTime";
|
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)
|
// Xibo bootstrap OAuth2 credentials (one-time global setup for orchestrator API access)
|
||||||
public const string CatXibo = "Xibo";
|
public const string CatXibo = "Xibo";
|
||||||
public const string XiboBootstrapClientId = "Xibo.BootstrapClientId";
|
public const string XiboBootstrapClientId = "Xibo.BootstrapClientId";
|
||||||
@@ -84,7 +75,6 @@ public class SettingsService
|
|||||||
// Instance-specific (keyed by abbreviation)
|
// Instance-specific (keyed by abbreviation)
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Builds a per-instance settings key for the MySQL password.
|
/// Builds a per-instance settings key for the MySQL password.
|
||||||
/// Stored encrypted via DataProtection so it can be retrieved on update/redeploy.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static string InstanceMySqlPassword(string abbrev) => $"Instance.{abbrev}.MySqlPassword";
|
public static string InstanceMySqlPassword(string abbrev) => $"Instance.{abbrev}.MySqlPassword";
|
||||||
/// <summary>Bitwarden secret ID for the instance's OTS admin password.</summary>
|
/// <summary>Bitwarden secret ID for the instance's OTS admin password.</summary>
|
||||||
@@ -95,79 +85,169 @@ public class SettingsService
|
|||||||
public static string InstanceOAuthClientId(string abbrev) => $"Instance.{abbrev}.OAuthClientId";
|
public static string InstanceOAuthClientId(string abbrev) => $"Instance.{abbrev}.OAuthClientId";
|
||||||
public const string CatInstance = "Instance";
|
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<string, (string Id, string Value)>? s_cache;
|
||||||
|
|
||||||
public SettingsService(
|
public SettingsService(
|
||||||
XiboContext db,
|
IBitwardenSecretService bws,
|
||||||
IDataProtectionProvider dataProtection,
|
|
||||||
ILogger<SettingsService> logger)
|
ILogger<SettingsService> logger)
|
||||||
{
|
{
|
||||||
_db = db;
|
_bws = bws;
|
||||||
_protector = dataProtection.CreateProtector("OTSSignsOrchestrator.Settings");
|
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Get a single setting value, decrypting if sensitive.</summary>
|
/// <summary>Get a single setting value from Bitwarden.</summary>
|
||||||
public async Task<string?> GetAsync(string key)
|
public async Task<string?> GetAsync(string key)
|
||||||
{
|
{
|
||||||
var setting = await _db.AppSettings.FindAsync(key);
|
var cache = await EnsureCacheAsync();
|
||||||
if (setting == null) return null;
|
var bwKey = KeyPrefix + key;
|
||||||
return setting.IsSensitive && setting.Value != null
|
if (!cache.TryGetValue(bwKey, out var entry))
|
||||||
? Unprotect(setting.Value)
|
return null;
|
||||||
: setting.Value;
|
// Treat single-space sentinel as empty (used to work around SDK marshalling limitation)
|
||||||
|
return string.IsNullOrWhiteSpace(entry.Value) ? null : entry.Value;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Get a setting with a fallback default.</summary>
|
/// <summary>Get a setting with a fallback default.</summary>
|
||||||
public async Task<string> GetAsync(string key, string defaultValue)
|
public async Task<string> GetAsync(string key, string defaultValue)
|
||||||
=> await GetAsync(key) ?? defaultValue;
|
=> await GetAsync(key) ?? defaultValue;
|
||||||
|
|
||||||
/// <summary>Set a single setting, encrypting if sensitive.</summary>
|
/// <summary>Set a single setting in Bitwarden (creates or updates).</summary>
|
||||||
public async Task SetAsync(string key, string? value, string category, bool isSensitive = false)
|
public async Task SetAsync(string key, string? value, string category, bool isSensitive = false)
|
||||||
{
|
{
|
||||||
var setting = await _db.AppSettings.FindAsync(key);
|
var cache = await EnsureCacheAsync();
|
||||||
if (setting == null)
|
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;
|
||||||
|
|
||||||
|
if (cache.TryGetValue(bwKey, out var existing))
|
||||||
{
|
{
|
||||||
setting = new AppSetting { Key = key, Category = category, IsSensitive = isSensitive };
|
// Update existing secret
|
||||||
_db.AppSettings.Add(setting);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setting.Value = isSensitive && value != null ? _protector.Protect(value) : value;
|
/// <summary>Save multiple settings in a batch.</summary>
|
||||||
setting.IsSensitive = isSensitive;
|
|
||||||
setting.Category = category;
|
|
||||||
setting.UpdatedAt = DateTime.UtcNow;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Save multiple settings in a single transaction.</summary>
|
|
||||||
public async Task SaveManyAsync(IEnumerable<(string Key, string? Value, string Category, bool IsSensitive)> settings)
|
public async Task SaveManyAsync(IEnumerable<(string Key, string? Value, string Category, bool IsSensitive)> settings)
|
||||||
{
|
{
|
||||||
|
var count = 0;
|
||||||
|
var errors = new List<string>();
|
||||||
foreach (var (key, value, category, isSensitive) in settings)
|
foreach (var (key, value, category, isSensitive) in settings)
|
||||||
await SetAsync(key, value, category, isSensitive);
|
|
||||||
|
|
||||||
await _db.SaveChangesAsync();
|
|
||||||
_logger.LogInformation("Saved {Count} setting(s)",
|
|
||||||
settings is ICollection<(string, string?, string, bool)> c ? c.Count : -1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Get all settings in a category (values decrypted).</summary>
|
|
||||||
public async Task<Dictionary<string, string?>> GetCategoryAsync(string category)
|
|
||||||
{
|
|
||||||
var settings = await _db.AppSettings
|
|
||||||
.Where(s => s.Category == category)
|
|
||||||
.ToListAsync();
|
|
||||||
|
|
||||||
return settings.ToDictionary(
|
|
||||||
s => s.Key,
|
|
||||||
s => s.IsSensitive && s.Value != null ? Unprotect(s.Value) : s.Value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private string? Unprotect(string protectedValue)
|
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return _protector.Unprotect(protectedValue);
|
await SetAsync(key, value, category, isSensitive);
|
||||||
|
count++;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to unprotect setting value — returning null");
|
_logger.LogWarning(ex, "Failed to save setting {Key}", key);
|
||||||
return null;
|
errors.Add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_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)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Get all settings in a category (by examining cached keys).</summary>
|
||||||
|
public async Task<Dictionary<string, string?>> GetCategoryAsync(string category)
|
||||||
|
{
|
||||||
|
var cache = await EnsureCacheAsync();
|
||||||
|
var prefix = KeyPrefix + category + ".";
|
||||||
|
var result = new Dictionary<string, string?>();
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Invalidates the in-memory cache so next access re-fetches from Bitwarden.
|
||||||
|
/// </summary>
|
||||||
|
public void InvalidateCache() => s_cache = null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pre-loads the settings cache from Bitwarden.
|
||||||
|
/// Call once at startup so settings are available immediately.
|
||||||
|
/// </summary>
|
||||||
|
public async Task PreloadCacheAsync()
|
||||||
|
{
|
||||||
|
InvalidateCache();
|
||||||
|
await EnsureCacheAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Cache management
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private async Task<Dictionary<string, (string Id, string Value)>> EnsureCacheAsync()
|
||||||
|
{
|
||||||
|
if (s_cache is not null)
|
||||||
|
return s_cache;
|
||||||
|
|
||||||
|
var cache = new Dictionary<string, (string, string)>(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
|
||||||
|
{
|
||||||
|
// 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.LogError(ex, "Failed to load settings from Bitwarden — settings will be empty until Bitwarden is configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
s_cache = cache;
|
||||||
|
return s_cache;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ namespace OTSSignsOrchestrator.Core.Services;
|
|||||||
/// Provides connectivity testing and administrative operations against deployed Xibo CMS instances.
|
/// Provides connectivity testing and administrative operations against deployed Xibo CMS instances.
|
||||||
///
|
///
|
||||||
/// Bootstrap flow:
|
/// Bootstrap flow:
|
||||||
/// 1. A Xibo OAuth2 application with client_credentials grant must be created once
|
/// 1. After a new instance is deployed, <see cref="PostInstanceInitService"/> calls
|
||||||
/// (stored in Settings → Xibo.BootstrapClientId / Xibo.BootstrapClientSecret).
|
/// <see cref="LoginAsync"/> with the default Xibo admin credentials to obtain a session cookie.
|
||||||
/// 2. After a new instance is deployed, PostInstanceInitService calls into this service
|
/// 2. Subsequent operations (create user, register OAuth2 app, set theme) authenticate
|
||||||
/// to create the OTS admin user, register a dedicated OAuth2 app, and set the theme.
|
/// using that session cookie — no pre-existing OAuth2 application is required.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class XiboApiService
|
public class XiboApiService
|
||||||
{
|
{
|
||||||
@@ -74,13 +74,60 @@ public class XiboApiService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Session login
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Obtains a Bearer access token using the OAuth2 <c>client_credentials</c> grant.
|
||||||
|
/// The caller must have previously created an OAuth2 application in the Xibo CMS
|
||||||
|
/// admin UI and provide the resulting <paramref name="clientId"/> and
|
||||||
|
/// <paramref name="clientSecret"/>.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<string> 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<string, string>("grant_type", "client_credentials"),
|
||||||
|
new KeyValuePair<string, string>("client_id", clientId),
|
||||||
|
new KeyValuePair<string, string>("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
|
// Health / readiness
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Polls <paramref name="instanceUrl"/> until Xibo returns a 200 from its
|
/// Polls <paramref name="instanceUrl"/> until Xibo is genuinely online by calling
|
||||||
/// <c>/about</c> endpoint or <paramref name="timeout"/> elapses.
|
/// the public <c>/about</c> page (no auth required) and confirming the response body
|
||||||
|
/// contains the word "Xibo". <paramref name="instanceUrl"/> must already include the
|
||||||
|
/// instance sub-path (e.g. <c>https://ots.ots-signs.com/ots</c>).
|
||||||
|
/// The JSON <c>/api/about</c> and <c>/api/clock</c> 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".
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<bool> WaitForReadyAsync(
|
public async Task<bool> WaitForReadyAsync(
|
||||||
string instanceUrl,
|
string instanceUrl,
|
||||||
@@ -91,26 +138,34 @@ public class XiboApiService
|
|||||||
var baseUrl = instanceUrl.TrimEnd('/');
|
var baseUrl = instanceUrl.TrimEnd('/');
|
||||||
var client = _httpClientFactory.CreateClient("XiboHealth");
|
var client = _httpClientFactory.CreateClient("XiboHealth");
|
||||||
client.Timeout = TimeSpan.FromSeconds(10);
|
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)
|
while (DateTime.UtcNow < deadline && !ct.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var response = await client.GetAsync($"{baseUrl}/api/about", ct);
|
var response = await client.GetAsync(healthUrl, ct);
|
||||||
if (response.IsSuccessStatusCode)
|
if (response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Xibo is ready: {Url}", baseUrl);
|
// 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;
|
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 */ }
|
catch { /* not yet available */ }
|
||||||
|
|
||||||
await Task.Delay(TimeSpan.FromSeconds(10), ct);
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,31 +175,33 @@ public class XiboApiService
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates a new super-admin user in the Xibo instance and returns its numeric ID.
|
/// Creates a new super-admin user in the Xibo instance and returns its numeric ID.
|
||||||
|
/// Authenticates using the supplied <paramref name="accessToken"/> (Bearer token
|
||||||
|
/// obtained from <see cref="LoginAsync"/>).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<int> CreateAdminUserAsync(
|
public async Task<int> CreateAdminUserAsync(
|
||||||
string instanceUrl,
|
string instanceUrl,
|
||||||
string bootstrapClientId,
|
string accessToken,
|
||||||
string bootstrapClientSecret,
|
|
||||||
string newUsername,
|
string newUsername,
|
||||||
string newPassword,
|
string newPassword,
|
||||||
string email)
|
string email,
|
||||||
|
int groupId)
|
||||||
{
|
{
|
||||||
var client = _httpClientFactory.CreateClient("XiboApi");
|
var client = _httpClientFactory.CreateClient("XiboApi");
|
||||||
var baseUrl = instanceUrl.TrimEnd('/');
|
var baseUrl = instanceUrl.TrimEnd('/');
|
||||||
|
|
||||||
var token = await GetTokenAsync(baseUrl, bootstrapClientId, bootstrapClientSecret, client);
|
SetBearer(client, accessToken);
|
||||||
SetBearer(client, token);
|
|
||||||
|
|
||||||
var form = new FormUrlEncodedContent(new[]
|
var form = new FormUrlEncodedContent(new[]
|
||||||
{
|
{
|
||||||
new KeyValuePair<string, string>("userName", newUsername),
|
new KeyValuePair<string, string>("userName", newUsername),
|
||||||
new KeyValuePair<string, string>("email", email),
|
new KeyValuePair<string, string>("email", email),
|
||||||
new KeyValuePair<string, string>("userTypeId", "1"), // Super Admin
|
new KeyValuePair<string, string>("userTypeId", "1"), // Super Admin
|
||||||
new KeyValuePair<string, string>("homePageId", "1"),
|
new KeyValuePair<string, string>("homePageId", "icondashboard.view"),
|
||||||
new KeyValuePair<string, string>("libraryQuota", "0"),
|
new KeyValuePair<string, string>("libraryQuota", "0"),
|
||||||
new KeyValuePair<string, string>("groupId", "1"),
|
new KeyValuePair<string, string>("groupId", groupId.ToString()),
|
||||||
new KeyValuePair<string, string>("newUserPassword", newPassword),
|
new KeyValuePair<string, string>("password", newPassword),
|
||||||
new KeyValuePair<string, string>("retypeNewUserPassword", newPassword),
|
new KeyValuePair<string, string>("newUserWizard", "0"),
|
||||||
|
new KeyValuePair<string, string>("hideNavigation", "0"),
|
||||||
new KeyValuePair<string, string>("isPasswordChangeRequired", "0"),
|
new KeyValuePair<string, string>("isPasswordChangeRequired", "0"),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -160,19 +217,19 @@ public class XiboApiService
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Changes the password of an existing Xibo user.
|
/// Changes the password of an existing Xibo user.
|
||||||
|
/// Authenticates using the supplied <paramref name="accessToken"/> (Bearer token
|
||||||
|
/// obtained from <see cref="LoginAsync"/>).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task RotateUserPasswordAsync(
|
public async Task RotateUserPasswordAsync(
|
||||||
string instanceUrl,
|
string instanceUrl,
|
||||||
string bootstrapClientId,
|
string accessToken,
|
||||||
string bootstrapClientSecret,
|
|
||||||
int userId,
|
int userId,
|
||||||
string newPassword)
|
string newPassword)
|
||||||
{
|
{
|
||||||
var client = _httpClientFactory.CreateClient("XiboApi");
|
var client = _httpClientFactory.CreateClient("XiboApi");
|
||||||
var baseUrl = instanceUrl.TrimEnd('/');
|
var baseUrl = instanceUrl.TrimEnd('/');
|
||||||
|
|
||||||
var token = await GetTokenAsync(baseUrl, bootstrapClientId, bootstrapClientSecret, client);
|
SetBearer(client, accessToken);
|
||||||
SetBearer(client, token);
|
|
||||||
|
|
||||||
var form = new FormUrlEncodedContent(new[]
|
var form = new FormUrlEncodedContent(new[]
|
||||||
{
|
{
|
||||||
@@ -193,18 +250,18 @@ public class XiboApiService
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Registers a new client_credentials OAuth2 application in Xibo and returns
|
/// Registers a new client_credentials OAuth2 application in Xibo and returns
|
||||||
/// the generated client_id and client_secret.
|
/// the generated client_id and client_secret.
|
||||||
|
/// Authenticates using the supplied <paramref name="accessToken"/> (Bearer token
|
||||||
|
/// obtained from <see cref="LoginAsync"/>).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<(string ClientId, string ClientSecret)> RegisterOAuthClientAsync(
|
public async Task<(string ClientId, string ClientSecret)> RegisterOAuthClientAsync(
|
||||||
string instanceUrl,
|
string instanceUrl,
|
||||||
string bootstrapClientId,
|
string accessToken,
|
||||||
string bootstrapClientSecret,
|
|
||||||
string appName)
|
string appName)
|
||||||
{
|
{
|
||||||
var client = _httpClientFactory.CreateClient("XiboApi");
|
var client = _httpClientFactory.CreateClient("XiboApi");
|
||||||
var baseUrl = instanceUrl.TrimEnd('/');
|
var baseUrl = instanceUrl.TrimEnd('/');
|
||||||
|
|
||||||
var token = await GetTokenAsync(baseUrl, bootstrapClientId, bootstrapClientSecret, client);
|
SetBearer(client, accessToken);
|
||||||
SetBearer(client, token);
|
|
||||||
|
|
||||||
var form = new FormUrlEncodedContent(new[]
|
var form = new FormUrlEncodedContent(new[]
|
||||||
{
|
{
|
||||||
@@ -235,18 +292,18 @@ public class XiboApiService
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Sets the active CMS theme by writing the THEME_FOLDER setting.
|
/// Sets the active CMS theme by writing the THEME_FOLDER setting.
|
||||||
|
/// Authenticates using the supplied <paramref name="accessToken"/> (Bearer token
|
||||||
|
/// obtained from <see cref="LoginAsync"/>).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task SetThemeAsync(
|
public async Task SetThemeAsync(
|
||||||
string instanceUrl,
|
string instanceUrl,
|
||||||
string bootstrapClientId,
|
string accessToken,
|
||||||
string bootstrapClientSecret,
|
|
||||||
string themeFolderName = "otssigns")
|
string themeFolderName = "otssigns")
|
||||||
{
|
{
|
||||||
var client = _httpClientFactory.CreateClient("XiboApi");
|
var client = _httpClientFactory.CreateClient("XiboApi");
|
||||||
var baseUrl = instanceUrl.TrimEnd('/');
|
var baseUrl = instanceUrl.TrimEnd('/');
|
||||||
|
|
||||||
var token = await GetTokenAsync(baseUrl, bootstrapClientId, bootstrapClientSecret, client);
|
SetBearer(client, accessToken);
|
||||||
SetBearer(client, token);
|
|
||||||
|
|
||||||
// Xibo stores settings as an array: settings[THEME_FOLDER]=otssigns
|
// Xibo stores settings as an array: settings[THEME_FOLDER]=otssigns
|
||||||
var form = new FormUrlEncodedContent(new[]
|
var form = new FormUrlEncodedContent(new[]
|
||||||
@@ -260,6 +317,152 @@ public class XiboApiService
|
|||||||
_logger.LogInformation("Xibo theme set to: {Theme}", themeFolderName);
|
_logger.LogInformation("Xibo theme set to: {Theme}", themeFolderName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// User groups
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new user group and returns its numeric group ID.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<int> 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<string, string>("group", groupName),
|
||||||
|
new KeyValuePair<string, string>("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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Assigns a user to a Xibo user group.
|
||||||
|
/// </summary>
|
||||||
|
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<string, string>("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
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates an existing Xibo user's username, password, and email.
|
||||||
|
/// Authenticates using the supplied <paramref name="accessToken"/>.
|
||||||
|
/// </summary>
|
||||||
|
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<string, string>("userName", newUsername),
|
||||||
|
new KeyValuePair<string, string>("email", email),
|
||||||
|
new KeyValuePair<string, string>("userTypeId", "1"),
|
||||||
|
new KeyValuePair<string, string>("homePageId", "icondashboard.view"),
|
||||||
|
new KeyValuePair<string, string>("libraryQuota", "0"),
|
||||||
|
new KeyValuePair<string, string>("newPassword", newPassword),
|
||||||
|
new KeyValuePair<string, string>("retypeNewPassword", newPassword),
|
||||||
|
new KeyValuePair<string, string>("newUserWizard", "0"),
|
||||||
|
new KeyValuePair<string, string>("hideNavigation", "0"),
|
||||||
|
new KeyValuePair<string, string>("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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Finds a Xibo user by username and returns their numeric user ID.
|
||||||
|
/// Authenticates using the supplied <paramref name="accessToken"/>.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<int> 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.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes a Xibo user by their numeric user ID.
|
||||||
|
/// Authenticates using the supplied <paramref name="accessToken"/>.
|
||||||
|
/// </summary>
|
||||||
|
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
|
// Helpers
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
@@ -302,6 +505,8 @@ public class XiboApiService
|
|||||||
private static void SetBearer(HttpClient client, string token)
|
private static void SetBearer(HttpClient client, string token)
|
||||||
=> client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
=> client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
private static async Task EnsureSuccessAsync(HttpResponseMessage response, string operation)
|
private static async Task EnsureSuccessAsync(HttpResponseMessage response, string operation)
|
||||||
{
|
{
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
|
|||||||
@@ -44,6 +44,28 @@ public class App : Application
|
|||||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||||
{
|
{
|
||||||
Log.Information("Creating MainWindow...");
|
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<SettingsService>();
|
||||||
|
await settings.PreloadCacheAsync();
|
||||||
|
Log.Information("Bitwarden config settings pre-loaded");
|
||||||
|
|
||||||
|
// Import existing instance secrets that aren't yet tracked
|
||||||
|
var postInit = Services.GetRequiredService<PostInstanceInitService>();
|
||||||
|
await postInit.ImportExistingInstanceSecretsAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning(ex, "Failed to pre-load settings or import instance secrets on startup");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
var vm = Services.GetRequiredService<MainWindowViewModel>();
|
var vm = Services.GetRequiredService<MainWindowViewModel>();
|
||||||
Log.Information("MainWindowViewModel resolved");
|
Log.Information("MainWindowViewModel resolved");
|
||||||
|
|
||||||
@@ -75,10 +97,10 @@ public class App : Application
|
|||||||
|
|
||||||
private static void ConfigureServices(IServiceCollection services)
|
private static void ConfigureServices(IServiceCollection services)
|
||||||
{
|
{
|
||||||
// Configuration
|
// Configuration (reloadOnChange so runtime writes to appsettings.json are picked up)
|
||||||
var config = new ConfigurationBuilder()
|
var config = new ConfigurationBuilder()
|
||||||
.SetBasePath(AppContext.BaseDirectory)
|
.SetBasePath(AppContext.BaseDirectory)
|
||||||
.AddJsonFile("appsettings.json", optional: false)
|
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
|
||||||
.Build();
|
.Build();
|
||||||
|
|
||||||
services.AddSingleton<IConfiguration>(config);
|
services.AddSingleton<IConfiguration>(config);
|
||||||
@@ -89,6 +111,7 @@ public class App : Application
|
|||||||
services.Configure<XiboOptions>(config.GetSection(XiboOptions.SectionName));
|
services.Configure<XiboOptions>(config.GetSection(XiboOptions.SectionName));
|
||||||
services.Configure<DatabaseOptions>(config.GetSection(DatabaseOptions.SectionName));
|
services.Configure<DatabaseOptions>(config.GetSection(DatabaseOptions.SectionName));
|
||||||
services.Configure<FileLoggingOptions>(config.GetSection(FileLoggingOptions.SectionName));
|
services.Configure<FileLoggingOptions>(config.GetSection(FileLoggingOptions.SectionName));
|
||||||
|
services.Configure<BitwardenOptions>(config.GetSection(BitwardenOptions.SectionName));
|
||||||
|
|
||||||
// Logging
|
// Logging
|
||||||
services.AddLogging(builder =>
|
services.AddLogging(builder =>
|
||||||
@@ -115,7 +138,6 @@ public class App : Application
|
|||||||
services.AddHttpClient();
|
services.AddHttpClient();
|
||||||
services.AddHttpClient("XiboApi");
|
services.AddHttpClient("XiboApi");
|
||||||
services.AddHttpClient("XiboHealth");
|
services.AddHttpClient("XiboHealth");
|
||||||
services.AddHttpClient("Bitwarden");
|
|
||||||
|
|
||||||
// SSH services (singletons — maintain connections)
|
// SSH services (singletons — maintain connections)
|
||||||
services.AddSingleton<SshConnectionService>();
|
services.AddSingleton<SshConnectionService>();
|
||||||
@@ -137,7 +159,7 @@ public class App : Application
|
|||||||
services.AddSingleton<PostInstanceInitService>();
|
services.AddSingleton<PostInstanceInitService>();
|
||||||
|
|
||||||
// ViewModels
|
// ViewModels
|
||||||
services.AddTransient<MainWindowViewModel>();
|
services.AddSingleton<MainWindowViewModel>(); // singleton: one main window, nav state shared
|
||||||
services.AddTransient<HostsViewModel>();
|
services.AddTransient<HostsViewModel>();
|
||||||
services.AddTransient<InstancesViewModel>();
|
services.AddTransient<InstancesViewModel>();
|
||||||
services.AddTransient<InstanceDetailsViewModel>();
|
services.AddTransient<InstanceDetailsViewModel>();
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
|
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
|
||||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||||
|
<!-- Ensure the Bitwarden SDK native runtime libraries are included on publish -->
|
||||||
|
<RuntimeIdentifiers>linux-x64;win-x64;osx-x64;osx-arm64</RuntimeIdentifiers>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ using OTSSignsOrchestrator.Core.Configuration;
|
|||||||
using OTSSignsOrchestrator.Core.Models.DTOs;
|
using OTSSignsOrchestrator.Core.Models.DTOs;
|
||||||
using OTSSignsOrchestrator.Core.Models.Entities;
|
using OTSSignsOrchestrator.Core.Models.Entities;
|
||||||
using OTSSignsOrchestrator.Core.Services;
|
using OTSSignsOrchestrator.Core.Services;
|
||||||
|
using ServiceLogEntry = OTSSignsOrchestrator.Core.Models.DTOs.ServiceLogEntry;
|
||||||
|
|
||||||
namespace OTSSignsOrchestrator.Desktop.Services;
|
namespace OTSSignsOrchestrator.Desktop.Services;
|
||||||
|
|
||||||
@@ -441,6 +442,121 @@ public class SshDockerCliService : IDockerCliService
|
|||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<List<ServiceLogEntry>> GetServiceLogsAsync(string stackName, string? serviceName = null, int tailLines = 200)
|
||||||
|
{
|
||||||
|
EnsureHost();
|
||||||
|
|
||||||
|
// Determine which services to fetch logs for
|
||||||
|
List<string> serviceNames;
|
||||||
|
if (!string.IsNullOrEmpty(serviceName))
|
||||||
|
{
|
||||||
|
serviceNames = new List<string> { serviceName };
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var services = await InspectStackServicesAsync(stackName);
|
||||||
|
serviceNames = services.Select(s => s.Name).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
var allEntries = new List<ServiceLogEntry>();
|
||||||
|
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:
|
||||||
|
// <timestamp> <service>.<replica>.<taskid>@<node> | <message>
|
||||||
|
// or sometimes just:
|
||||||
|
// <timestamp> <service>.<replica>.<taskid> <message>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses a single line from <c>docker service logs --timestamps</c> output.
|
||||||
|
/// </summary>
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Strips the stack name prefix from a fully-qualified service name.
|
||||||
|
/// e.g. "acm-cms-stack_acm-web" → "acm-web"
|
||||||
|
/// </summary>
|
||||||
|
private static string StripStackPrefix(string serviceName, string stackName)
|
||||||
|
{
|
||||||
|
var prefix = stackName + "_";
|
||||||
|
return serviceName.StartsWith(prefix) ? serviceName[prefix.Length..] : serviceName;
|
||||||
|
}
|
||||||
|
|
||||||
private void EnsureHost()
|
private void EnsureHost()
|
||||||
{
|
{
|
||||||
if (_currentHost == null)
|
if (_currentHost == null)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ namespace OTSSignsOrchestrator.Desktop.ViewModels;
|
|||||||
public partial class CreateInstanceViewModel : ObservableObject
|
public partial class CreateInstanceViewModel : ObservableObject
|
||||||
{
|
{
|
||||||
private readonly IServiceProvider _services;
|
private readonly IServiceProvider _services;
|
||||||
|
private readonly MainWindowViewModel _mainVm;
|
||||||
|
|
||||||
[ObservableProperty] private string _statusMessage = string.Empty;
|
[ObservableProperty] private string _statusMessage = string.Empty;
|
||||||
[ObservableProperty] private bool _isBusy;
|
[ObservableProperty] private bool _isBusy;
|
||||||
@@ -43,6 +44,9 @@ public partial class CreateInstanceViewModel : ObservableObject
|
|||||||
[ObservableProperty] private string _nfsExportFolder = string.Empty;
|
[ObservableProperty] private string _nfsExportFolder = string.Empty;
|
||||||
[ObservableProperty] private string _nfsExtraOptions = string.Empty;
|
[ObservableProperty] private string _nfsExtraOptions = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>When enabled, existing Docker volumes for the stack are removed before deploying.</summary>
|
||||||
|
[ObservableProperty] private bool _purgeStaleVolumes = false;
|
||||||
|
|
||||||
// SSH host selection
|
// SSH host selection
|
||||||
[ObservableProperty] private ObservableCollection<SshHost> _availableHosts = new();
|
[ObservableProperty] private ObservableCollection<SshHost> _availableHosts = new();
|
||||||
[ObservableProperty] private SshHost? _selectedSshHost;
|
[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;
|
_services = services;
|
||||||
|
_mainVm = mainVm;
|
||||||
_ = LoadHostsAsync();
|
_ = LoadHostsAsync();
|
||||||
_ = LoadNfsDefaultsAsync();
|
_ = LoadNfsDefaultsAsync();
|
||||||
}
|
}
|
||||||
@@ -308,16 +313,25 @@ public partial class CreateInstanceViewModel : ObservableObject
|
|||||||
NfsExport = string.IsNullOrWhiteSpace(NfsExport) ? null : NfsExport.Trim(),
|
NfsExport = string.IsNullOrWhiteSpace(NfsExport) ? null : NfsExport.Trim(),
|
||||||
NfsExportFolder = string.IsNullOrWhiteSpace(NfsExportFolder) ? null : NfsExportFolder.Trim(),
|
NfsExportFolder = string.IsNullOrWhiteSpace(NfsExportFolder) ? null : NfsExportFolder.Trim(),
|
||||||
NfsExtraOptions = string.IsNullOrWhiteSpace(NfsExtraOptions) ? null : NfsExtraOptions.Trim(),
|
NfsExtraOptions = string.IsNullOrWhiteSpace(NfsExtraOptions) ? null : NfsExtraOptions.Trim(),
|
||||||
|
PurgeStaleVolumes = PurgeStaleVolumes,
|
||||||
};
|
};
|
||||||
|
|
||||||
var result = await instanceSvc.CreateInstanceAsync(dto);
|
var result = await instanceSvc.CreateInstanceAsync(dto);
|
||||||
|
|
||||||
AppendOutput(result.Output ?? string.Empty);
|
AppendOutput(result.Output ?? string.Empty);
|
||||||
SetProgress(100, result.Success ? "Deployment complete!" : "Deployment failed.");
|
|
||||||
|
|
||||||
StatusMessage = result.Success
|
if (result.Success)
|
||||||
? $"Instance '{Abbrev}-cms-stack' deployed in {result.DurationMs}ms!"
|
{
|
||||||
: $"Deploy failed: {result.ErrorMessage}";
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -45,7 +45,13 @@ public partial class InstanceDetailsViewModel : ObservableObject
|
|||||||
// ── Status ────────────────────────────────────────────────────────────────
|
// ── Status ────────────────────────────────────────────────────────────────
|
||||||
[ObservableProperty] private string _statusMessage = string.Empty;
|
[ObservableProperty] private string _statusMessage = string.Empty;
|
||||||
[ObservableProperty] private bool _isBusy;
|
[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)
|
public InstanceDetailsViewModel(IServiceProvider services)
|
||||||
{
|
{
|
||||||
_services = services;
|
_services = services;
|
||||||
@@ -58,6 +64,7 @@ public partial class InstanceDetailsViewModel : ObservableObject
|
|||||||
/// <summary>Populates the ViewModel from a live <see cref="LiveStackItem"/>.</summary>
|
/// <summary>Populates the ViewModel from a live <see cref="LiveStackItem"/>.</summary>
|
||||||
public async Task LoadAsync(LiveStackItem instance)
|
public async Task LoadAsync(LiveStackItem instance)
|
||||||
{
|
{
|
||||||
|
_currentInstance = instance;
|
||||||
StackName = instance.StackName;
|
StackName = instance.StackName;
|
||||||
CustomerAbbrev = instance.CustomerAbbrev;
|
CustomerAbbrev = instance.CustomerAbbrev;
|
||||||
HostLabel = instance.HostLabel;
|
HostLabel = instance.HostLabel;
|
||||||
@@ -75,7 +82,7 @@ public partial class InstanceDetailsViewModel : ObservableObject
|
|||||||
var serverTemplate = await settings.GetAsync(
|
var serverTemplate = await settings.GetAsync(
|
||||||
SettingsService.DefaultCmsServerNameTemplate, "{abbrev}.ots-signs.com");
|
SettingsService.DefaultCmsServerNameTemplate, "{abbrev}.ots-signs.com");
|
||||||
var serverName = serverTemplate.Replace("{abbrev}", instance.CustomerAbbrev);
|
var serverName = serverTemplate.Replace("{abbrev}", instance.CustomerAbbrev);
|
||||||
InstanceUrl = $"https://{serverName}";
|
InstanceUrl = $"https://{serverName}/{instance.CustomerAbbrev.Trim().ToLowerInvariant()}";
|
||||||
|
|
||||||
// ── Admin credentials ─────────────────────────────────────────
|
// ── Admin credentials ─────────────────────────────────────────
|
||||||
var creds = await postInit.GetCredentialsAsync(instance.CustomerAbbrev);
|
var creds = await postInit.GetCredentialsAsync(instance.CustomerAbbrev);
|
||||||
@@ -95,7 +102,15 @@ public partial class InstanceDetailsViewModel : ObservableObject
|
|||||||
|
|
||||||
StatusMessage = creds.HasAdminPassword
|
StatusMessage = creds.HasAdminPassword
|
||||||
? "Credentials loaded."
|
? "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)
|
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<PostInstanceInitService>();
|
||||||
|
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
|
// Visibility toggles
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
|
using Avalonia.Threading;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using OTSSignsOrchestrator.Core.Data;
|
using OTSSignsOrchestrator.Core.Data;
|
||||||
|
using OTSSignsOrchestrator.Core.Models.DTOs;
|
||||||
using OTSSignsOrchestrator.Core.Models.Entities;
|
using OTSSignsOrchestrator.Core.Models.Entities;
|
||||||
using OTSSignsOrchestrator.Core.Services;
|
using OTSSignsOrchestrator.Core.Services;
|
||||||
using OTSSignsOrchestrator.Desktop.Models;
|
using OTSSignsOrchestrator.Desktop.Models;
|
||||||
@@ -30,15 +32,37 @@ public partial class InstancesViewModel : ObservableObject
|
|||||||
[ObservableProperty] private ObservableCollection<SshHost> _availableHosts = new();
|
[ObservableProperty] private ObservableCollection<SshHost> _availableHosts = new();
|
||||||
[ObservableProperty] private SshHost? _selectedSshHost;
|
[ObservableProperty] private SshHost? _selectedSshHost;
|
||||||
|
|
||||||
|
// ── Container Logs ──────────────────────────────────────────────────────
|
||||||
|
[ObservableProperty] private ObservableCollection<ServiceLogEntry> _logEntries = new();
|
||||||
|
[ObservableProperty] private ObservableCollection<string> _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;
|
||||||
|
|
||||||
/// <summary>Raised when the instance details modal should be opened for the given ViewModel.</summary>
|
/// <summary>Raised when the instance details modal should be opened for the given ViewModel.</summary>
|
||||||
public event Action<InstanceDetailsViewModel>? OpenDetailsRequested;
|
public event Action<InstanceDetailsViewModel>? OpenDetailsRequested;
|
||||||
|
|
||||||
|
private string? _pendingSelectAbbrev;
|
||||||
|
|
||||||
public InstancesViewModel(IServiceProvider services)
|
public InstancesViewModel(IServiceProvider services)
|
||||||
{
|
{
|
||||||
_services = services;
|
_services = services;
|
||||||
_ = RefreshAllAsync();
|
_ = RefreshAllAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Queues an abbreviation to be auto-selected once the next live refresh completes.
|
||||||
|
/// Call immediately after construction (before <see cref="RefreshAllAsync"/> finishes).
|
||||||
|
/// </summary>
|
||||||
|
public void SetPendingSelection(string abbrev)
|
||||||
|
=> _pendingSelectAbbrev = abbrev;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Enumerates all SSH hosts, then calls docker stack ls on each to build the
|
/// Enumerates all SSH hosts, then calls docker stack ls on each to build the
|
||||||
/// live instance list. Only stacks matching *-cms-stack are shown.
|
/// 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();
|
i.HostLabel.Contains(FilterText, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||||
|
|
||||||
Instances = new ObservableCollection<LiveStackItem>(all);
|
Instances = new ObservableCollection<LiveStackItem>(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).";
|
var msg = $"Found {all.Count} instance(s) across {hosts.Count} host(s).";
|
||||||
if (errors.Count > 0) msg += $" | Errors: {string.Join(" | ", errors)}";
|
if (errors.Count > 0) msg += $" | Errors: {string.Join(" | ", errors)}";
|
||||||
StatusMessage = msg;
|
StatusMessage = msg;
|
||||||
@@ -110,11 +143,108 @@ public partial class InstancesViewModel : ObservableObject
|
|||||||
var services = await dockerCli.InspectStackServicesAsync(SelectedInstance.StackName);
|
var services = await dockerCli.InspectStackServicesAsync(SelectedInstance.StackName);
|
||||||
SelectedServices = new ObservableCollection<ServiceInfo>(services);
|
SelectedServices = new ObservableCollection<ServiceInfo>(services);
|
||||||
StatusMessage = $"Found {services.Count} service(s) in stack '{SelectedInstance.StackName}'.";
|
StatusMessage = $"Found {services.Count} service(s) in stack '{SelectedInstance.StackName}'.";
|
||||||
|
|
||||||
|
// Populate service filter dropdown and show logs panel
|
||||||
|
var filterItems = new List<string> { "All Services" };
|
||||||
|
filterItems.AddRange(services.Select(s => s.Name));
|
||||||
|
LogServiceFilter = new ObservableCollection<string>(filterItems);
|
||||||
|
SelectedLogService = "All Services";
|
||||||
|
IsLogsPanelVisible = true;
|
||||||
|
|
||||||
|
// Fetch initial logs and start auto-refresh
|
||||||
|
await FetchLogsInternalAsync();
|
||||||
|
StartLogAutoRefresh();
|
||||||
}
|
}
|
||||||
catch (Exception ex) { StatusMessage = $"Error inspecting: {ex.Message}"; }
|
catch (Exception ex) { StatusMessage = $"Error inspecting: {ex.Message}"; }
|
||||||
finally { IsBusy = false; }
|
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<ServiceLogEntry>();
|
||||||
|
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<SshDockerCliService>();
|
||||||
|
dockerCli.SetHost(SelectedInstance.Host);
|
||||||
|
|
||||||
|
string? serviceFilter = SelectedLogService == "All Services" ? null : SelectedLogService;
|
||||||
|
var entries = await dockerCli.GetServiceLogsAsync(
|
||||||
|
SelectedInstance.StackName, serviceFilter, LogTailLines);
|
||||||
|
|
||||||
|
LogEntries = new ObservableCollection<ServiceLogEntry>(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]
|
[RelayCommand]
|
||||||
private async Task DeleteInstanceAsync()
|
private async Task DeleteInstanceAsync()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -53,6 +53,17 @@ public partial class MainWindowViewModel : ObservableObject
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Navigates to the Instances page and auto-selects the instance with the given abbreviation
|
||||||
|
/// once the live refresh completes.
|
||||||
|
/// </summary>
|
||||||
|
public void NavigateToInstancesWithSelection(string abbrev)
|
||||||
|
{
|
||||||
|
SelectedNav = "Instances"; // triggers OnSelectedNavChanged → NavigateTo("Instances")
|
||||||
|
if (CurrentView is InstancesViewModel instancesVm)
|
||||||
|
instancesVm.SetPendingSelection(abbrev);
|
||||||
|
}
|
||||||
|
|
||||||
public void SetStatus(string message)
|
public void SetStatus(string message)
|
||||||
{
|
{
|
||||||
StatusMessage = message;
|
StatusMessage = message;
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using OTSSignsOrchestrator.Core.Configuration;
|
||||||
using OTSSignsOrchestrator.Core.Services;
|
using OTSSignsOrchestrator.Core.Services;
|
||||||
|
|
||||||
namespace OTSSignsOrchestrator.Desktop.ViewModels;
|
namespace OTSSignsOrchestrator.Desktop.ViewModels;
|
||||||
@@ -65,6 +69,7 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
[ObservableProperty] private string _bitwardenAccessToken = string.Empty;
|
[ObservableProperty] private string _bitwardenAccessToken = string.Empty;
|
||||||
[ObservableProperty] private string _bitwardenOrganizationId = string.Empty;
|
[ObservableProperty] private string _bitwardenOrganizationId = string.Empty;
|
||||||
[ObservableProperty] private string _bitwardenProjectId = string.Empty;
|
[ObservableProperty] private string _bitwardenProjectId = string.Empty;
|
||||||
|
[ObservableProperty] private string _bitwardenInstanceProjectId = string.Empty;
|
||||||
|
|
||||||
// ── Xibo Bootstrap OAuth2 ─────────────────────────────────────
|
// ── Xibo Bootstrap OAuth2 ─────────────────────────────────────
|
||||||
[ObservableProperty] private string _xiboBootstrapClientId = string.Empty;
|
[ObservableProperty] private string _xiboBootstrapClientId = string.Empty;
|
||||||
@@ -76,12 +81,34 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
_ = LoadAsync();
|
_ = LoadAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Whether Bitwarden is configured and reachable.</summary>
|
||||||
|
[ObservableProperty] private bool _isBitwardenConfigured;
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task LoadAsync()
|
private async Task LoadAsync()
|
||||||
{
|
{
|
||||||
IsBusy = true;
|
IsBusy = true;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
// ── Load Bitwarden bootstrap config from IOptions<BitwardenOptions> ──
|
||||||
|
var bwOptions = _services.GetRequiredService<IOptions<BitwardenOptions>>().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();
|
using var scope = _services.CreateScope();
|
||||||
var svc = scope.ServiceProvider.GetRequiredService<SettingsService>();
|
var svc = scope.ServiceProvider.GetRequiredService<SettingsService>();
|
||||||
|
|
||||||
@@ -127,18 +154,11 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
DefaultPhpUploadMaxFilesize = await svc.GetAsync(SettingsService.DefaultPhpUploadMaxFilesize, "10G");
|
DefaultPhpUploadMaxFilesize = await svc.GetAsync(SettingsService.DefaultPhpUploadMaxFilesize, "10G");
|
||||||
DefaultPhpMaxExecutionTime = await svc.GetAsync(SettingsService.DefaultPhpMaxExecutionTime, "600");
|
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
|
// Xibo Bootstrap
|
||||||
XiboBootstrapClientId = await svc.GetAsync(SettingsService.XiboBootstrapClientId, string.Empty);
|
XiboBootstrapClientId = await svc.GetAsync(SettingsService.XiboBootstrapClientId, string.Empty);
|
||||||
XiboBootstrapClientSecret = await svc.GetAsync(SettingsService.XiboBootstrapClientSecret, string.Empty);
|
XiboBootstrapClientSecret = await svc.GetAsync(SettingsService.XiboBootstrapClientSecret, string.Empty);
|
||||||
|
|
||||||
StatusMessage = "Settings loaded.";
|
StatusMessage = "Settings loaded from Bitwarden.";
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -156,8 +176,23 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
IsBusy = true;
|
IsBusy = true;
|
||||||
try
|
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();
|
using var scope = _services.CreateScope();
|
||||||
var svc = scope.ServiceProvider.GetRequiredService<SettingsService>();
|
var svc = scope.ServiceProvider.GetRequiredService<SettingsService>();
|
||||||
|
svc.InvalidateCache(); // force re-read after config change
|
||||||
|
|
||||||
var settings = new List<(string Key, string? Value, string Category, bool IsSensitive)>
|
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.DefaultPhpUploadMaxFilesize, DefaultPhpUploadMaxFilesize, SettingsService.CatDefaults, false),
|
||||||
(SettingsService.DefaultPhpMaxExecutionTime, DefaultPhpMaxExecutionTime, 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
|
// Xibo Bootstrap
|
||||||
(SettingsService.XiboBootstrapClientId, NullIfEmpty(XiboBootstrapClientId), SettingsService.CatXibo, false),
|
(SettingsService.XiboBootstrapClientId, NullIfEmpty(XiboBootstrapClientId), SettingsService.CatXibo, false),
|
||||||
(SettingsService.XiboBootstrapClientSecret, NullIfEmpty(XiboBootstrapClientSecret), SettingsService.CatXibo, true),
|
(SettingsService.XiboBootstrapClientSecret, NullIfEmpty(XiboBootstrapClientSecret), SettingsService.CatXibo, true),
|
||||||
};
|
};
|
||||||
|
|
||||||
await svc.SaveManyAsync(settings);
|
await svc.SaveManyAsync(settings);
|
||||||
StatusMessage = "Settings saved successfully.";
|
StatusMessage = "Settings saved to Bitwarden.";
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -238,16 +266,21 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
|
|
||||||
IsBusy = true;
|
IsBusy = true;
|
||||||
StatusMessage = "Testing Bitwarden Secrets Manager connection...";
|
StatusMessage = "Saving Bitwarden config and testing connection...";
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
// Save to appsettings.json first so the service picks up fresh values
|
||||||
|
await SaveBitwardenConfigToFileAsync();
|
||||||
|
|
||||||
using var scope = _services.CreateScope();
|
using var scope = _services.CreateScope();
|
||||||
var bws = scope.ServiceProvider.GetRequiredService<IBitwardenSecretService>();
|
var bws = scope.ServiceProvider.GetRequiredService<IBitwardenSecretService>();
|
||||||
var secrets = await bws.ListSecretsAsync();
|
var secrets = await bws.ListSecretsAsync();
|
||||||
|
IsBitwardenConfigured = true;
|
||||||
StatusMessage = $"Bitwarden connected. Found {secrets.Count} secret(s) in project.";
|
StatusMessage = $"Bitwarden connected. Found {secrets.Count} secret(s) in project.";
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
IsBitwardenConfigured = false;
|
||||||
StatusMessage = $"Bitwarden connection failed: {ex.Message}";
|
StatusMessage = $"Bitwarden connection failed: {ex.Message}";
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
@@ -324,6 +357,33 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Persists Bitwarden bootstrap credentials to appsettings.json so they survive restarts.
|
||||||
|
/// </summary>
|
||||||
|
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)
|
private static string? NullIfEmpty(string? value)
|
||||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,6 +78,20 @@
|
|||||||
</Border>
|
</Border>
|
||||||
</Expander>
|
</Expander>
|
||||||
|
|
||||||
|
<!-- Advanced options -->
|
||||||
|
<Expander Header="Advanced options">
|
||||||
|
<Border Classes="card" Margin="0,8,0,0">
|
||||||
|
<StackPanel Spacing="8">
|
||||||
|
<CheckBox IsChecked="{Binding PurgeStaleVolumes}">
|
||||||
|
<StackPanel Spacing="2">
|
||||||
|
<TextBlock Text="Purge stale volumes before deploying" FontSize="12" />
|
||||||
|
<TextBlock Text="Removes existing Docker volumes for this stack so fresh volumes are created. Only needed if volumes were created with wrong settings." FontSize="11" Foreground="{StaticResource TextMutedBrush}" TextWrapping="Wrap" />
|
||||||
|
</StackPanel>
|
||||||
|
</CheckBox>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</Expander>
|
||||||
|
|
||||||
<!-- Deploy button + progress -->
|
<!-- Deploy button + progress -->
|
||||||
<Button Content="Deploy Instance"
|
<Button Content="Deploy Instance"
|
||||||
Classes="accent"
|
Classes="accent"
|
||||||
|
|||||||
@@ -31,6 +31,22 @@
|
|||||||
<ScrollViewer>
|
<ScrollViewer>
|
||||||
<StackPanel Spacing="16">
|
<StackPanel Spacing="16">
|
||||||
|
|
||||||
|
<!-- ═══ Pending Setup Banner ═══ -->
|
||||||
|
<Border IsVisible="{Binding IsPendingSetup}"
|
||||||
|
Background="#1F2A1A" BorderBrush="#4ADE80" BorderThickness="1"
|
||||||
|
CornerRadius="8" Padding="14,10">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
|
<TextBlock Text="⚙" FontSize="18" VerticalAlignment="Center" Foreground="#4ADE80" />
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Text="Pending Setup" FontSize="14" FontWeight="SemiBold"
|
||||||
|
Foreground="#4ADE80" />
|
||||||
|
<TextBlock FontSize="12" Foreground="{StaticResource TextSecondaryBrush}"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
Text="Enter your Xibo OAuth credentials below to complete instance initialisation." />
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
<!-- ═══ OTS Admin Account ═══ -->
|
<!-- ═══ OTS Admin Account ═══ -->
|
||||||
<Border Classes="card">
|
<Border Classes="card">
|
||||||
<StackPanel Spacing="8">
|
<StackPanel Spacing="8">
|
||||||
@@ -116,7 +132,35 @@
|
|||||||
FontSize="12" Foreground="{StaticResource TextMutedBrush}" Margin="0,0,0,6"
|
FontSize="12" Foreground="{StaticResource TextMutedBrush}" Margin="0,0,0,6"
|
||||||
TextWrapping="Wrap" />
|
TextWrapping="Wrap" />
|
||||||
|
|
||||||
<TextBlock Text="Client ID" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
<!-- ── Pending: editable credential input ── -->
|
||||||
|
<StackPanel Spacing="8" IsVisible="{Binding IsPendingSetup}">
|
||||||
|
<TextBlock FontSize="12" Foreground="{StaticResource TextSecondaryBrush}"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
Text="Log into the Xibo CMS as xibo_admin (password: password), go to Administration → Applications, create a client_credentials app, then paste the credentials here." />
|
||||||
|
|
||||||
|
<TextBlock Text="Client ID" FontSize="12"
|
||||||
|
Foreground="{StaticResource TextSecondaryBrush}" Margin="0,4,0,0" />
|
||||||
|
<TextBox Text="{Binding InitClientId}" Watermark="OAuth2 Client ID" />
|
||||||
|
|
||||||
|
<TextBlock Text="Client Secret" FontSize="12"
|
||||||
|
Foreground="{StaticResource TextSecondaryBrush}" />
|
||||||
|
<TextBox Text="{Binding InitClientSecret}" PasswordChar="●"
|
||||||
|
Watermark="(paste from Xibo Applications page)" />
|
||||||
|
|
||||||
|
<Button Content="Initialize Instance"
|
||||||
|
Command="{Binding InitializeCommand}"
|
||||||
|
IsEnabled="{Binding !IsBusy}"
|
||||||
|
Classes="accent"
|
||||||
|
HorizontalAlignment="Stretch"
|
||||||
|
HorizontalContentAlignment="Center"
|
||||||
|
Padding="14,10" FontSize="14"
|
||||||
|
Margin="0,8,0,0" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- ── Initialized: read-only display ── -->
|
||||||
|
<StackPanel Spacing="8" IsVisible="{Binding !IsPendingSetup}">
|
||||||
|
<TextBlock Text="Client ID" FontSize="12"
|
||||||
|
Foreground="{StaticResource TextSecondaryBrush}" />
|
||||||
<Grid ColumnDefinitions="*,Auto">
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
<TextBox Grid.Column="0" Text="{Binding OAuthClientId}" IsReadOnly="True"
|
<TextBox Grid.Column="0" Text="{Binding OAuthClientId}" IsReadOnly="True"
|
||||||
FontFamily="Consolas,monospace" />
|
FontFamily="Consolas,monospace" />
|
||||||
@@ -124,7 +168,8 @@
|
|||||||
Command="{Binding CopyOAuthClientIdCommand}" />
|
Command="{Binding CopyOAuthClientIdCommand}" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<TextBlock Text="Client Secret" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" Margin="0,4,0,0" />
|
<TextBlock Text="Client Secret" FontSize="12"
|
||||||
|
Foreground="{StaticResource TextSecondaryBrush}" Margin="0,4,0,0" />
|
||||||
<Grid ColumnDefinitions="*,Auto,Auto,Auto">
|
<Grid ColumnDefinitions="*,Auto,Auto,Auto">
|
||||||
<TextBox Grid.Column="0" Text="{Binding OAuthSecretDisplay}" IsReadOnly="True"
|
<TextBox Grid.Column="0" Text="{Binding OAuthSecretDisplay}" IsReadOnly="True"
|
||||||
FontFamily="Consolas,monospace" />
|
FontFamily="Consolas,monospace" />
|
||||||
@@ -138,6 +183,7 @@
|
|||||||
Command="{Binding CopyOAuthSecretCommand}" />
|
Command="{Binding CopyOAuthSecretCommand}" />
|
||||||
</Grid>
|
</Grid>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<UserControl xmlns="https://github.com/avaloniaui"
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:vm="using:OTSSignsOrchestrator.Desktop.ViewModels"
|
xmlns:vm="using:OTSSignsOrchestrator.Desktop.ViewModels"
|
||||||
|
xmlns:dto="using:OTSSignsOrchestrator.Core.Models.DTOs"
|
||||||
x:Class="OTSSignsOrchestrator.Desktop.Views.InstancesView"
|
x:Class="OTSSignsOrchestrator.Desktop.Views.InstancesView"
|
||||||
x:DataType="vm:InstancesViewModel">
|
x:DataType="vm:InstancesViewModel">
|
||||||
|
|
||||||
@@ -37,8 +38,30 @@
|
|||||||
<TextBlock DockPanel.Dock="Bottom" Text="{Binding StatusMessage}" Classes="status"
|
<TextBlock DockPanel.Dock="Bottom" Text="{Binding StatusMessage}" Classes="status"
|
||||||
Margin="0,10,0,0" />
|
Margin="0,10,0,0" />
|
||||||
|
|
||||||
|
<!-- Main content: split into upper (grid + services) and lower (logs) -->
|
||||||
|
<Grid RowDefinitions="*,Auto,Auto">
|
||||||
|
|
||||||
|
<!-- Upper area: instance list + services side panel -->
|
||||||
|
<Grid Grid.Row="0" ColumnDefinitions="*,Auto">
|
||||||
|
|
||||||
|
<!-- Instance list -->
|
||||||
|
<DataGrid Grid.Column="0"
|
||||||
|
ItemsSource="{Binding Instances}"
|
||||||
|
SelectedItem="{Binding SelectedInstance}"
|
||||||
|
AutoGenerateColumns="False"
|
||||||
|
IsReadOnly="True"
|
||||||
|
GridLinesVisibility="Horizontal"
|
||||||
|
CanUserResizeColumns="True">
|
||||||
|
<DataGrid.Columns>
|
||||||
|
<DataGridTextColumn Header="Stack" Binding="{Binding StackName}" Width="150" />
|
||||||
|
<DataGridTextColumn Header="Abbrev" Binding="{Binding CustomerAbbrev}" Width="70" />
|
||||||
|
<DataGridTextColumn Header="Services" Binding="{Binding ServiceCount}" Width="70" />
|
||||||
|
<DataGridTextColumn Header="Host" Binding="{Binding HostLabel}" Width="150" />
|
||||||
|
</DataGrid.Columns>
|
||||||
|
</DataGrid>
|
||||||
|
|
||||||
<!-- Services panel (shown when inspecting) -->
|
<!-- Services panel (shown when inspecting) -->
|
||||||
<Border DockPanel.Dock="Right" Width="360"
|
<Border Grid.Column="1" Width="360"
|
||||||
IsVisible="{Binding SelectedServices.Count}"
|
IsVisible="{Binding SelectedServices.Count}"
|
||||||
Classes="card" Margin="16,0,0,0">
|
Classes="card" Margin="16,0,0,0">
|
||||||
<StackPanel Spacing="4">
|
<StackPanel Spacing="4">
|
||||||
@@ -62,20 +85,71 @@
|
|||||||
</ItemsControl>
|
</ItemsControl>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
<!-- Instance list -->
|
<!-- Grid splitter between instances and logs -->
|
||||||
<DataGrid ItemsSource="{Binding Instances}"
|
<GridSplitter Grid.Row="1" Height="6" HorizontalAlignment="Stretch"
|
||||||
SelectedItem="{Binding SelectedInstance}"
|
IsVisible="{Binding IsLogsPanelVisible}"
|
||||||
AutoGenerateColumns="False"
|
Background="Transparent" />
|
||||||
IsReadOnly="True"
|
|
||||||
GridLinesVisibility="Horizontal"
|
<!-- Container Logs Panel -->
|
||||||
CanUserResizeColumns="True">
|
<Border Grid.Row="2" Classes="card" Margin="0,4,0,0"
|
||||||
<DataGrid.Columns>
|
IsVisible="{Binding IsLogsPanelVisible}"
|
||||||
<DataGridTextColumn Header="Stack" Binding="{Binding StackName}" Width="150" />
|
MinHeight="180" MaxHeight="400">
|
||||||
<DataGridTextColumn Header="Abbrev" Binding="{Binding CustomerAbbrev}" Width="70" />
|
<DockPanel>
|
||||||
<DataGridTextColumn Header="Services" Binding="{Binding ServiceCount}" Width="70" />
|
<!-- Logs toolbar -->
|
||||||
<DataGridTextColumn Header="Host" Binding="{Binding HostLabel}" Width="150" />
|
<Grid DockPanel.Dock="Top" ColumnDefinitions="Auto,*,Auto" Margin="0,0,0,8">
|
||||||
</DataGrid.Columns>
|
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="8"
|
||||||
</DataGrid>
|
VerticalAlignment="Center">
|
||||||
|
<TextBlock Text="Container Logs" Classes="sectionTitle"
|
||||||
|
Foreground="{StaticResource AccentBrush}" />
|
||||||
|
<ComboBox ItemsSource="{Binding LogServiceFilter}"
|
||||||
|
SelectedItem="{Binding SelectedLogService}"
|
||||||
|
MinWidth="200" FontSize="12"
|
||||||
|
ToolTip.Tip="Filter logs by service" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<TextBlock Grid.Column="1" Text="{Binding LogsStatusMessage}"
|
||||||
|
FontSize="11" Foreground="{StaticResource TextMutedBrush}"
|
||||||
|
VerticalAlignment="Center" HorizontalAlignment="Center" />
|
||||||
|
|
||||||
|
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6"
|
||||||
|
VerticalAlignment="Center">
|
||||||
|
<Button Content="Refresh" Command="{Binding RefreshLogsCommand}"
|
||||||
|
FontSize="11" Padding="8,4"
|
||||||
|
ToolTip.Tip="Fetch latest logs" />
|
||||||
|
<ToggleButton IsChecked="{Binding IsLogsAutoRefresh}"
|
||||||
|
Content="Auto"
|
||||||
|
FontSize="11" Padding="8,4"
|
||||||
|
ToolTip.Tip="Toggle auto-refresh (every 5 seconds)"
|
||||||
|
Command="{Binding ToggleLogsAutoRefreshCommand}" />
|
||||||
|
<Button Content="✕" Command="{Binding CloseLogsPanelCommand}"
|
||||||
|
FontSize="11" Padding="6,4"
|
||||||
|
ToolTip.Tip="Close logs panel" />
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- Log entries list -->
|
||||||
|
<Border Background="#1a1a2e" CornerRadius="4" Padding="8"
|
||||||
|
BorderBrush="{StaticResource BorderSubtleBrush}" BorderThickness="1">
|
||||||
|
<ScrollViewer VerticalScrollBarVisibility="Auto"
|
||||||
|
HorizontalScrollBarVisibility="Auto">
|
||||||
|
<ItemsControl ItemsSource="{Binding LogEntries}"
|
||||||
|
x:DataType="vm:InstancesViewModel">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="dto:ServiceLogEntry">
|
||||||
|
<TextBlock Text="{Binding DisplayLine}"
|
||||||
|
FontFamily="Cascadia Mono,Consolas,Menlo,monospace"
|
||||||
|
FontSize="11" Padding="0,1"
|
||||||
|
TextWrapping="NoWrap"
|
||||||
|
Foreground="#cccccc" />
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</ScrollViewer>
|
||||||
|
</Border>
|
||||||
|
</DockPanel>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
</UserControl>
|
</UserControl>
|
||||||
|
|||||||
@@ -29,6 +29,59 @@
|
|||||||
<ScrollViewer>
|
<ScrollViewer>
|
||||||
<StackPanel Spacing="16" Margin="0,0,16,16" MaxWidth="820">
|
<StackPanel Spacing="16" Margin="0,0,16,16" MaxWidth="820">
|
||||||
|
|
||||||
|
<!-- ═══ Bitwarden Secrets Manager (Bootstrap — always shown first) ═══ -->
|
||||||
|
<Border Classes="card">
|
||||||
|
<StackPanel Spacing="8">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8" Margin="0,0,0,4">
|
||||||
|
<Border Width="4" Height="20" CornerRadius="2" Background="#818CF8" />
|
||||||
|
<TextBlock Text="Bitwarden Secrets Manager" FontSize="16" FontWeight="SemiBold"
|
||||||
|
Foreground="#818CF8" VerticalAlignment="Center" />
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock Text="All application settings are stored in Bitwarden. Configure these credentials first — they are saved to appsettings.json on disk."
|
||||||
|
FontSize="12" Foreground="{StaticResource TextMutedBrush}" Margin="0,0,0,6"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
|
||||||
|
<Grid ColumnDefinitions="1*,12,1*">
|
||||||
|
<StackPanel Grid.Column="0" Spacing="4">
|
||||||
|
<TextBlock Text="Identity URL" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||||
|
<TextBox Text="{Binding BitwardenIdentityUrl}"
|
||||||
|
Watermark="https://identity.bitwarden.com" />
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Grid.Column="2" Spacing="4">
|
||||||
|
<TextBlock Text="API URL" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||||
|
<TextBox Text="{Binding BitwardenApiUrl}"
|
||||||
|
Watermark="https://api.bitwarden.com" />
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<TextBlock Text="Machine Account Access Token" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||||
|
<TextBox Text="{Binding BitwardenAccessToken}" PasswordChar="●"
|
||||||
|
Watermark="0.xxxxxxxx.yyyyyyy:zzzzzz" />
|
||||||
|
|
||||||
|
<TextBlock Text="Organization ID" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||||
|
<TextBox Text="{Binding BitwardenOrganizationId}"
|
||||||
|
Watermark="00000000-0000-0000-0000-000000000000" />
|
||||||
|
|
||||||
|
<TextBlock Text="Project ID (required — config secrets are stored in this project)" FontSize="12"
|
||||||
|
Foreground="{StaticResource TextSecondaryBrush}" />
|
||||||
|
<TextBox Text="{Binding BitwardenProjectId}"
|
||||||
|
Watermark="00000000-0000-0000-0000-000000000000" />
|
||||||
|
|
||||||
|
<TextBlock Text="Instance Project ID (optional — instance secrets like DB passwords go here; falls back to Project ID if empty)" FontSize="12"
|
||||||
|
Foreground="{StaticResource TextSecondaryBrush}" TextWrapping="Wrap" />
|
||||||
|
<TextBox Text="{Binding BitwardenInstanceProjectId}"
|
||||||
|
Watermark="00000000-0000-0000-0000-000000000000 (leave empty to use default project)" />
|
||||||
|
|
||||||
|
<Button Content="Test Bitwarden Connection"
|
||||||
|
Command="{Binding TestBitwardenConnectionCommand}"
|
||||||
|
IsEnabled="{Binding !IsBusy}"
|
||||||
|
Margin="0,6,0,0" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- ═══ Remaining settings — disabled until Bitwarden is configured ═══ -->
|
||||||
|
<StackPanel Spacing="16" IsEnabled="{Binding IsBitwardenConfigured}">
|
||||||
|
|
||||||
<!-- ═══ Git Repository ═══ -->
|
<!-- ═══ Git Repository ═══ -->
|
||||||
<Border Classes="card">
|
<Border Classes="card">
|
||||||
<StackPanel Spacing="8">
|
<StackPanel Spacing="8">
|
||||||
@@ -231,51 +284,6 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!-- ═══ Bitwarden Secrets Manager ═══ -->
|
|
||||||
<Border Classes="card">
|
|
||||||
<StackPanel Spacing="8">
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="8" Margin="0,0,0,4">
|
|
||||||
<Border Width="4" Height="20" CornerRadius="2" Background="#818CF8" />
|
|
||||||
<TextBlock Text="Bitwarden Secrets Manager" FontSize="16" FontWeight="SemiBold"
|
|
||||||
Foreground="#818CF8" VerticalAlignment="Center" />
|
|
||||||
</StackPanel>
|
|
||||||
<TextBlock Text="Stores per-instance admin passwords and OAuth2 secrets. Uses a machine account access token."
|
|
||||||
FontSize="12" Foreground="{StaticResource TextMutedBrush}" Margin="0,0,0,6"
|
|
||||||
TextWrapping="Wrap" />
|
|
||||||
|
|
||||||
<Grid ColumnDefinitions="1*,12,1*">
|
|
||||||
<StackPanel Grid.Column="0" Spacing="4">
|
|
||||||
<TextBlock Text="Identity URL" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
|
||||||
<TextBox Text="{Binding BitwardenIdentityUrl}"
|
|
||||||
Watermark="https://identity.bitwarden.com" />
|
|
||||||
</StackPanel>
|
|
||||||
<StackPanel Grid.Column="2" Spacing="4">
|
|
||||||
<TextBlock Text="API URL" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
|
||||||
<TextBox Text="{Binding BitwardenApiUrl}"
|
|
||||||
Watermark="https://api.bitwarden.com" />
|
|
||||||
</StackPanel>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<TextBlock Text="Machine Account Access Token" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
|
||||||
<TextBox Text="{Binding BitwardenAccessToken}" PasswordChar="●"
|
|
||||||
Watermark="0.xxxxxxxx.yyyyyyy:zzzzzz" />
|
|
||||||
|
|
||||||
<TextBlock Text="Organization ID" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
|
||||||
<TextBox Text="{Binding BitwardenOrganizationId}"
|
|
||||||
Watermark="00000000-0000-0000-0000-000000000000" />
|
|
||||||
|
|
||||||
<TextBlock Text="Project ID (optional — secrets are organized into this project)" FontSize="12"
|
|
||||||
Foreground="{StaticResource TextSecondaryBrush}" />
|
|
||||||
<TextBox Text="{Binding BitwardenProjectId}"
|
|
||||||
Watermark="00000000-0000-0000-0000-000000000000" />
|
|
||||||
|
|
||||||
<Button Content="Test Bitwarden Connection"
|
|
||||||
Command="{Binding TestBitwardenConnectionCommand}"
|
|
||||||
IsEnabled="{Binding !IsBusy}"
|
|
||||||
Margin="0,6,0,0" />
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<!-- ═══ Xibo Bootstrap OAuth2 ═══ -->
|
<!-- ═══ Xibo Bootstrap OAuth2 ═══ -->
|
||||||
<Border Classes="card">
|
<Border Classes="card">
|
||||||
<StackPanel Spacing="8">
|
<StackPanel Spacing="8">
|
||||||
@@ -302,6 +310,8 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
</StackPanel> <!-- end of IsBitwardenConfigured wrapper -->
|
||||||
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
|
|||||||
@@ -31,6 +31,14 @@
|
|||||||
"Database": {
|
"Database": {
|
||||||
"Provider": "Sqlite"
|
"Provider": "Sqlite"
|
||||||
},
|
},
|
||||||
|
"Bitwarden": {
|
||||||
|
"IdentityUrl": "https://identity.bitwarden.com",
|
||||||
|
"ApiUrl": "https://api.bitwarden.com",
|
||||||
|
"AccessToken": "",
|
||||||
|
"OrganizationId": "",
|
||||||
|
"ProjectId": "",
|
||||||
|
"InstanceProjectId": ""
|
||||||
|
},
|
||||||
"ConnectionStrings": {
|
"ConnectionStrings": {
|
||||||
"Default": "Data Source=otssigns-desktop.db"
|
"Default": "Data Source=otssigns-desktop.db"
|
||||||
},
|
},
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -38,6 +38,12 @@ services:
|
|||||||
- {{ABBREV}}-cms-ca-certs:/var/www/cms/ca-certs
|
- {{ABBREV}}-cms-ca-certs:/var/www/cms/ca-certs
|
||||||
ports:
|
ports:
|
||||||
- "{{HOST_HTTP_PORT}}:80"
|
- "{{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:
|
networks:
|
||||||
{{ABBREV}}-net:
|
{{ABBREV}}-net:
|
||||||
aliases:
|
aliases:
|
||||||
@@ -79,6 +85,9 @@ services:
|
|||||||
PANGOLIN_ENDPOINT: {{PANGOLIN_ENDPOINT}}
|
PANGOLIN_ENDPOINT: {{PANGOLIN_ENDPOINT}}
|
||||||
NEWT_ID: {{NEWT_ID}}
|
NEWT_ID: {{NEWT_ID}}
|
||||||
NEWT_SECRET: {{NEWT_SECRET}}
|
NEWT_SECRET: {{NEWT_SECRET}}
|
||||||
|
depends_on:
|
||||||
|
{{ABBREV}}-web:
|
||||||
|
condition: service_healthy
|
||||||
networks:
|
networks:
|
||||||
{{ABBREV}}-net: {}
|
{{ABBREV}}-net: {}
|
||||||
deploy:
|
deploy:
|
||||||
|
|||||||
Reference in New Issue
Block a user