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,21 +1,20 @@
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OTSSignsOrchestrator.Core.Data;
|
||||
using OTSSignsOrchestrator.Core.Models.Entities;
|
||||
|
||||
namespace OTSSignsOrchestrator.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Reads and writes typed application settings from the AppSetting table.
|
||||
/// Sensitive values are encrypted/decrypted transparently via DataProtection.
|
||||
/// Reads and writes application settings from Bitwarden Secrets Manager.
|
||||
/// Each setting is stored as a Bitwarden secret with key prefix "ots-config/".
|
||||
/// The secret's Note field stores metadata (category|isSensitive).
|
||||
/// </summary>
|
||||
public class SettingsService
|
||||
{
|
||||
private readonly XiboContext _db;
|
||||
private readonly IDataProtector _protector;
|
||||
private readonly IBitwardenSecretService _bws;
|
||||
private readonly ILogger<SettingsService> _logger;
|
||||
|
||||
/// <summary>Prefix applied to all config secret keys in Bitwarden.</summary>
|
||||
private const string KeyPrefix = "ots-config/";
|
||||
|
||||
// ── Category constants ─────────────────────────────────────────────────
|
||||
public const string CatGit = "Git";
|
||||
public const string CatMySql = "MySql";
|
||||
@@ -68,14 +67,6 @@ public class SettingsService
|
||||
public const string DefaultPhpUploadMaxFilesize = "Defaults.PhpUploadMaxFilesize";
|
||||
public const string DefaultPhpMaxExecutionTime = "Defaults.PhpMaxExecutionTime";
|
||||
|
||||
// Bitwarden Secrets Manager
|
||||
public const string CatBitwarden = "Bitwarden";
|
||||
public const string BitwardenIdentityUrl = "Bitwarden.IdentityUrl";
|
||||
public const string BitwardenApiUrl = "Bitwarden.ApiUrl";
|
||||
public const string BitwardenAccessToken = "Bitwarden.AccessToken";
|
||||
public const string BitwardenOrganizationId = "Bitwarden.OrganizationId";
|
||||
public const string BitwardenProjectId = "Bitwarden.ProjectId";
|
||||
|
||||
// Xibo bootstrap OAuth2 credentials (one-time global setup for orchestrator API access)
|
||||
public const string CatXibo = "Xibo";
|
||||
public const string XiboBootstrapClientId = "Xibo.BootstrapClientId";
|
||||
@@ -84,7 +75,6 @@ public class SettingsService
|
||||
// Instance-specific (keyed by abbreviation)
|
||||
/// <summary>
|
||||
/// Builds a per-instance settings key for the MySQL password.
|
||||
/// Stored encrypted via DataProtection so it can be retrieved on update/redeploy.
|
||||
/// </summary>
|
||||
public static string InstanceMySqlPassword(string abbrev) => $"Instance.{abbrev}.MySqlPassword";
|
||||
/// <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 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(
|
||||
XiboContext db,
|
||||
IDataProtectionProvider dataProtection,
|
||||
IBitwardenSecretService bws,
|
||||
ILogger<SettingsService> logger)
|
||||
{
|
||||
_db = db;
|
||||
_protector = dataProtection.CreateProtector("OTSSignsOrchestrator.Settings");
|
||||
_bws = bws;
|
||||
_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)
|
||||
{
|
||||
var setting = await _db.AppSettings.FindAsync(key);
|
||||
if (setting == null) return null;
|
||||
return setting.IsSensitive && setting.Value != null
|
||||
? Unprotect(setting.Value)
|
||||
: setting.Value;
|
||||
var cache = await EnsureCacheAsync();
|
||||
var bwKey = KeyPrefix + key;
|
||||
if (!cache.TryGetValue(bwKey, out var entry))
|
||||
return null;
|
||||
// Treat single-space sentinel as empty (used to work around SDK marshalling limitation)
|
||||
return string.IsNullOrWhiteSpace(entry.Value) ? null : entry.Value;
|
||||
}
|
||||
|
||||
/// <summary>Get a setting with a fallback default.</summary>
|
||||
public async Task<string> GetAsync(string key, string 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)
|
||||
{
|
||||
var setting = await _db.AppSettings.FindAsync(key);
|
||||
if (setting == null)
|
||||
{
|
||||
setting = new AppSetting { Key = key, Category = category, IsSensitive = isSensitive };
|
||||
_db.AppSettings.Add(setting);
|
||||
}
|
||||
var cache = await EnsureCacheAsync();
|
||||
var bwKey = KeyPrefix + key;
|
||||
var note = $"{category}|{(isSensitive ? "sensitive" : "plain")}";
|
||||
// Use a single space for empty/null values — the Bitwarden SDK native FFI
|
||||
// cannot marshal empty strings reliably.
|
||||
var safeValue = string.IsNullOrEmpty(value) ? " " : value;
|
||||
|
||||
setting.Value = isSensitive && value != null ? _protector.Protect(value) : value;
|
||||
setting.IsSensitive = isSensitive;
|
||||
setting.Category = category;
|
||||
setting.UpdatedAt = DateTime.UtcNow;
|
||||
if (cache.TryGetValue(bwKey, out var existing))
|
||||
{
|
||||
// Update existing secret
|
||||
await _bws.UpdateSecretAsync(existing.Id, bwKey, safeValue, note);
|
||||
cache[bwKey] = (existing.Id, safeValue);
|
||||
s_cache = cache;
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
// Only create new secrets when there is an actual value to store
|
||||
var newId = await _bws.CreateSecretAsync(bwKey, safeValue, note);
|
||||
cache[bwKey] = (newId, safeValue);
|
||||
s_cache = cache;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Save multiple settings in a single transaction.</summary>
|
||||
/// <summary>Save multiple settings in a batch.</summary>
|
||||
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)
|
||||
await SetAsync(key, value, category, isSensitive);
|
||||
{
|
||||
try
|
||||
{
|
||||
await SetAsync(key, value, category, isSensitive);
|
||||
count++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to save setting {Key}", key);
|
||||
errors.Add(key);
|
||||
}
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
_logger.LogInformation("Saved {Count} setting(s)",
|
||||
settings is ICollection<(string, string?, string, bool)> c ? c.Count : -1);
|
||||
_logger.LogInformation("Saved {Count} setting(s) to Bitwarden", count);
|
||||
|
||||
if (errors.Count > 0)
|
||||
throw new AggregateException(
|
||||
$"Failed to save {errors.Count} setting(s): {string.Join(", ", errors)}");
|
||||
}
|
||||
|
||||
/// <summary>Get all settings in a category (values decrypted).</summary>
|
||||
/// <summary>Get all settings in a category (by examining cached keys).</summary>
|
||||
public async Task<Dictionary<string, string?>> GetCategoryAsync(string category)
|
||||
{
|
||||
var settings = await _db.AppSettings
|
||||
.Where(s => s.Category == category)
|
||||
.ToListAsync();
|
||||
var cache = await EnsureCacheAsync();
|
||||
var prefix = KeyPrefix + category + ".";
|
||||
var result = new Dictionary<string, string?>();
|
||||
|
||||
return settings.ToDictionary(
|
||||
s => s.Key,
|
||||
s => s.IsSensitive && s.Value != null ? Unprotect(s.Value) : s.Value);
|
||||
foreach (var (bwKey, entry) in cache)
|
||||
{
|
||||
if (bwKey.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Strip the "ots-config/" prefix to return the original key
|
||||
var originalKey = bwKey[KeyPrefix.Length..];
|
||||
result[originalKey] = entry.Value;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private string? Unprotect(string protectedValue)
|
||||
/// <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
|
||||
{
|
||||
return _protector.Unprotect(protectedValue);
|
||||
// List all secrets, then fetch full value for those matching our prefix
|
||||
var summaries = await _bws.ListSecretsAsync();
|
||||
var configSecrets = summaries
|
||||
.Where(s => s.Key.StartsWith(KeyPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
|
||||
_logger.LogInformation("Loading {Count} config secrets from Bitwarden", configSecrets.Count);
|
||||
|
||||
foreach (var summary in configSecrets)
|
||||
{
|
||||
try
|
||||
{
|
||||
var full = await _bws.GetSecretAsync(summary.Id);
|
||||
cache[full.Key] = (full.Id, full.Value);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to load secret {Key} ({Id})", summary.Key, summary.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to unprotect setting value — returning null");
|
||||
return null;
|
||||
_logger.LogError(ex, "Failed to load settings from Bitwarden — settings will be empty until Bitwarden is configured");
|
||||
}
|
||||
|
||||
s_cache = cache;
|
||||
return s_cache;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user