262 lines
11 KiB
C#
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;
|
|
}
|
|
}
|