Files
OTSSignsOrchestrator/OTSSignsOrchestrator.Core/Services/SettingsService.cs
2026-02-27 17:48:21 -05:00

262 lines
11 KiB
C#

using Microsoft.Extensions.Logging;
namespace OTSSignsOrchestrator.Core.Services;
/// <summary>
/// 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 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";
public const string CatSmtp = "Smtp";
public const string CatPangolin = "Pangolin";
public const string CatNfs = "Nfs";
public const string CatDefaults = "Defaults";
public const string CatAuthentik = "Authentik";
// ── Key constants ──────────────────────────────────────────────────────
// Git
public const string GitRepoUrl = "Git.RepoUrl";
public const string GitRepoPat = "Git.RepoPat";
// MySQL Admin
public const string MySqlHost = "MySql.Host";
public const string MySqlPort = "MySql.Port";
public const string MySqlAdminUser = "MySql.AdminUser";
public const string MySqlAdminPassword = "MySql.AdminPassword";
// SMTP
public const string SmtpServer = "Smtp.Server";
public const string SmtpPort = "Smtp.Port";
public const string SmtpUsername = "Smtp.Username";
public const string SmtpPassword = "Smtp.Password";
public const string SmtpUseTls = "Smtp.UseTls";
public const string SmtpUseStartTls = "Smtp.UseStartTls";
public const string SmtpRewriteDomain = "Smtp.RewriteDomain";
public const string SmtpHostname = "Smtp.Hostname";
public const string SmtpFromLineOverride = "Smtp.FromLineOverride";
// Pangolin
public const string PangolinEndpoint = "Pangolin.Endpoint";
// NFS
public const string NfsServer = "Nfs.Server";
public const string NfsExport = "Nfs.Export";
public const string NfsExportFolder = "Nfs.ExportFolder";
public const string NfsOptions = "Nfs.Options";
// Instance Defaults
public const string DefaultCmsImage = "Defaults.CmsImage";
public const string DefaultNewtImage = "Defaults.NewtImage";
public const string DefaultMemcachedImage = "Defaults.MemcachedImage";
public const string DefaultQuickChartImage = "Defaults.QuickChartImage";
public const string DefaultCmsServerNameTemplate = "Defaults.CmsServerNameTemplate";
public const string DefaultThemeHostPath = "Defaults.ThemeHostPath";
public const string DefaultMySqlDbTemplate = "Defaults.MySqlDbTemplate";
public const string DefaultMySqlUserTemplate = "Defaults.MySqlUserTemplate";
public const string DefaultPhpPostMaxSize = "Defaults.PhpPostMaxSize";
public const string DefaultPhpUploadMaxFilesize = "Defaults.PhpUploadMaxFilesize";
public const string DefaultPhpMaxExecutionTime = "Defaults.PhpMaxExecutionTime";
// Xibo bootstrap OAuth2 credentials (one-time global setup for orchestrator API access)
public const string CatXibo = "Xibo";
public const string XiboBootstrapClientId = "Xibo.BootstrapClientId";
public const string XiboBootstrapClientSecret = "Xibo.BootstrapClientSecret";
// Authentik (SAML IdP provisioning)
public const string AuthentikUrl = "Authentik.Url";
public const string AuthentikApiKey = "Authentik.ApiKey";
public const string AuthentikAuthorizationFlowSlug = "Authentik.AuthorizationFlowSlug";
public const string AuthentikInvalidationFlowSlug = "Authentik.InvalidationFlowSlug";
public const string AuthentikSigningKeypairId = "Authentik.SigningKeypairId";
// Instance-specific (keyed by abbreviation)
/// <summary>
/// Builds a per-instance settings key for the MySQL password.
/// </summary>
public static string InstanceMySqlPassword(string abbrev) => $"Instance.{abbrev}.MySqlPassword";
/// <summary>Bitwarden secret ID for the instance's OTS admin password.</summary>
public static string InstanceAdminPasswordSecretId(string abbrev) => $"Instance.{abbrev}.AdminPasswordBwsId";
/// <summary>Bitwarden secret ID for the instance's Xibo OAuth2 client secret.</summary>
public static string InstanceOAuthSecretId(string abbrev) => $"Instance.{abbrev}.OAuthSecretBwsId";
/// <summary>Xibo OAuth2 client_id generated for this instance's OTS application.</summary>
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(
IBitwardenSecretService bws,
ILogger<SettingsService> logger)
{
_bws = bws;
_logger = logger;
}
/// <summary>Get a single setting value from Bitwarden.</summary>
public async Task<string?> GetAsync(string key)
{
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 in Bitwarden (creates or updates).</summary>
public async Task SetAsync(string key, string? value, string category, bool isSensitive = false)
{
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;
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 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)
{
try
{
await SetAsync(key, value, category, isSensitive);
count++;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to save setting {Key}", key);
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;
}
}