diff --git a/SW.Bitween.Api/Data/SecretColumnCipher.cs b/SW.Bitween.Api/Data/SecretColumnCipher.cs
index 45f98d8b..afe9bd09 100644
--- a/SW.Bitween.Api/Data/SecretColumnCipher.cs
+++ b/SW.Bitween.Api/Data/SecretColumnCipher.cs
@@ -27,6 +27,11 @@ namespace SW.Bitween;
/// change the key, move the old one to Bitween:PreviousSettingsEncryptionKey: values are read
/// with either and written with the new one.
///
+///
+/// A key that is gone for good leaves nothing to read them with. Bitween:SettingsEncryptionKeyLost
+/// then reads such a value as empty, so it can be entered again, and leaves what is stored in place
+/// until it is: should the key turn up after all, whatever wasn't entered again reads once more.
+///
///
public static class SecretColumnCipher
{
@@ -41,14 +46,19 @@ public static class SecretColumnCipher
private static byte[] _key;
private static byte[] _previousKey;
+ private static bool _keyLost;
/// Whether values are being encrypted as they are written.
public static bool Enabled => _key is not null;
- public static void Configure(string passphrase, string previousPassphrase = null)
+ /// Whether a value no key here can read is taken as empty rather than refused.
+ public static bool KeyLost => _keyLost;
+
+ public static void Configure(string passphrase, string previousPassphrase = null, bool keyLost = false)
{
_key = string.IsNullOrWhiteSpace(passphrase) ? null : Derive(passphrase);
_previousKey = string.IsNullOrWhiteSpace(previousPassphrase) ? null : Derive(previousPassphrase);
+ _keyLost = keyLost;
}
public static string Protect(string plaintext)
@@ -66,7 +76,12 @@ public static string Protect(string plaintext)
return Prefix + Convert.ToBase64String(output);
}
- public static string Unprotect(string stored)
+ public static string Unprotect(string stored) => Unprotect(stored, unreadable: null);
+
+ /// The same for JSON, where a value that can't be read is JSON's own empty value.
+ private static string UnprotectJson(string stored) => Unprotect(stored, unreadable: "null");
+
+ private static string Unprotect(string stored, string unreadable)
{
if (stored is null || !stored.StartsWith(Prefix, StringComparison.Ordinal))
return stored;
@@ -85,10 +100,8 @@ public static string Unprotect(string stored)
}
}
- throw new InvalidOperationException(
- "A stored value is encrypted with a key this instance does not have. Set " +
- "Bitween:SettingsEncryptionKey to the key it was written with, or put that key in " +
- "Bitween:PreviousSettingsEncryptionKey while moving to a new one.");
+ if (_keyLost) return unreadable;
+ throw new UnreadableSecretException();
}
private static string Decrypt(byte[] key, byte[] input)
@@ -115,7 +128,7 @@ public static PropertyBuilder Encrypted(this PropertyBuilder builder)
$"{builder.Metadata.Name} has no converter to encrypt the output of; call StoreAsJson first.");
var comparer = builder.Metadata.GetValueComparer();
- var cipher = new ValueConverter(v => Protect(v), v => Unprotect(v));
+ var cipher = new ValueConverter(v => Protect(v), v => UnprotectJson(v));
builder.Metadata.SetValueConverter(inner.ComposeWith(cipher));
if (comparer is not null) builder.Metadata.SetValueComparer(comparer);
return builder;
@@ -153,7 +166,7 @@ private static T FromJsonb(string stored) where T : class
{
if (stored is null) return null;
var json = stored.TrimStart().StartsWith("\"" + Prefix, StringComparison.Ordinal)
- ? Unprotect(JsonConvert.DeserializeObject(stored))
+ ? UnprotectJson(JsonConvert.DeserializeObject(stored))
: stored;
return JsonConvert.DeserializeObject(json);
}
@@ -184,3 +197,9 @@ private static System.Collections.Generic.Dictionary MapValues(
return result;
}
}
+
+/// A stored credential that neither of this instance's keys can decrypt.
+public class UnreadableSecretException() : InvalidOperationException(
+ "A stored value is encrypted with a key this instance does not have. Set " +
+ "Bitween:SettingsEncryptionKey to the key it was written with, or put that key in " +
+ "Bitween:PreviousSettingsEncryptionKey while moving to a new one.");
diff --git a/SW.Bitween.Api/Resources/Ops/LaneResolver.cs b/SW.Bitween.Api/Resources/Ops/LaneResolver.cs
index 2b0f5a1e..e37b08f0 100644
--- a/SW.Bitween.Api/Resources/Ops/LaneResolver.cs
+++ b/SW.Bitween.Api/Resources/Ops/LaneResolver.cs
@@ -25,7 +25,7 @@ public enum QueueLane
/// A work group's -Result lane. Evaluates notifiers.
Notifications,
- /// A legacy event type, consumed only while that setting is on.
+ /// A legacy event type, consumed only while its queue still holds something.
Legacy,
/// Bookkeeping. No integration traffic.
@@ -50,7 +50,7 @@ public class LaneResolver(BitweenDbContext dbContext, BitweenOptions options)
["SubscriptionUnpausedEvent"] = "Resumed integrations",
};
- /// Only declared while Bitween.ConsumeLegacyEventMessages is on.
+ /// Only read while their queues still hold something; see .
private static readonly Dictionary Legacy = new(System.StringComparer.OrdinalIgnoreCase)
{
["ApiXchangeCreatedEvent"] = "API exchange created",
diff --git a/SW.Bitween.Api/Services/BitweenOptions.cs b/SW.Bitween.Api/Services/BitweenOptions.cs
index 793fa417..f842ee94 100644
--- a/SW.Bitween.Api/Services/BitweenOptions.cs
+++ b/SW.Bitween.Api/Services/BitweenOptions.cs
@@ -324,7 +324,6 @@ public BitweenOptions()
/// partnerkey keeps working either way. Read per request.
///
public string PartnerKeyHeader { get; set; }
- public bool ConsumeLegacyEventMessages { get; set; }
public string QueuePrefix { get; set; }
///
diff --git a/SW.Bitween.Api/Services/BrokerQueues.cs b/SW.Bitween.Api/Services/BrokerQueues.cs
new file mode 100644
index 00000000..8bb11fa3
--- /dev/null
+++ b/SW.Bitween.Api/Services/BrokerQueues.cs
@@ -0,0 +1,70 @@
+using System;
+using System.Threading.Tasks;
+using RabbitMQ.Client;
+using RabbitMQ.Client.Exceptions;
+using SW.Bus;
+
+namespace SW.Bitween.Services;
+
+///
+/// Bitween's own queues on RabbitMQ, looked at before the bus starts: whether one exists, what is in
+/// it, and removing one nothing uses any more. The bus itself only ever declares, which would create
+/// the very queue being asked about.
+///
+public sealed class BrokerQueues : IAsyncDisposable
+{
+ private readonly IConnection _connection;
+
+ private BrokerQueues(IConnection connection) => _connection = connection;
+
+ public static async Task ConnectAsync(string connectionString)
+ {
+ var factory = new ConnectionFactory
+ {
+ Uri = new Uri(connectionString),
+ AutomaticRecoveryEnabled = false,
+ RequestedConnectionTimeout = TimeSpan.FromSeconds(15)
+ };
+ return new BrokerQueues(await factory.CreateConnectionAsync("bitween-startup"));
+ }
+
+ ///
+ /// The name the bus gives a consumer's queue: v3.{environment}.{queue prefix}.{consumer}.{message}.
+ ///
+ public static string QueueName(string environment, string queuePrefix, string consumer, string message) =>
+ $"{new BusOptions(environment).ProcessExchange}.{queuePrefix}.{consumer}.{message}".ToLowerInvariant();
+
+ /// The queue's message and consumer counts, or null when there is no such queue.
+ public async Task InspectAsync(string queue)
+ {
+ // Asking about a missing queue closes the channel it was asked on, so each question gets its own.
+ await using var channel = await _connection.CreateChannelAsync();
+ try
+ {
+ return await channel.QueueDeclarePassiveAsync(queue);
+ }
+ catch (OperationInterruptedException ex) when (ex.ShutdownReason?.ReplyCode == 404)
+ {
+ return null;
+ }
+ }
+
+ /// Deletes the queue only while it is empty and nobody consumes it.
+ /// Whether it was deleted.
+ public async Task DeleteIfIdleAsync(string queue)
+ {
+ await using var channel = await _connection.CreateChannelAsync();
+ try
+ {
+ await channel.QueueDeleteAsync(queue, ifUnused: true, ifEmpty: true);
+ return true;
+ }
+ catch (OperationInterruptedException)
+ {
+ // Not empty, or consumed, by now: it waits for the next start.
+ return false;
+ }
+ }
+
+ public ValueTask DisposeAsync() => _connection.DisposeAsync();
+}
diff --git a/SW.Bitween.Api/Services/BusEnvironment.cs b/SW.Bitween.Api/Services/BusEnvironment.cs
new file mode 100644
index 00000000..b62f28d5
--- /dev/null
+++ b/SW.Bitween.Api/Services/BusEnvironment.cs
@@ -0,0 +1,125 @@
+using System;
+using System.Linq;
+using System.Threading.Tasks;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using SW.Bitween.Domain;
+
+namespace SW.Bitween.Services;
+
+///
+/// The bus names its exchange and every queue after ASPNETCORE_ENVIRONMENT. A new name moves Bitween
+/// to new queues while the other applications on the same RabbitMQ keep sending to the old ones, and
+/// what was waiting in them stays there. So a name Bitween didn't run with before is refused until
+/// it is confirmed.
+///
+///
+/// Not a hypothetical: the chart's default environment changed from Development to Production, so a
+/// deployment that never chose one changed it on upgrade without anyone deciding to.
+///
+public static class BusEnvironment
+{
+ ///
+ /// The name Bitween last ran with. A Settings row nobody edits and the settings page doesn't list,
+ /// as .
+ ///
+ public const string RecordedKey = "Bitween.BusEnvironment";
+
+ public const string ConfirmSetting = "Bitween:ConfirmBusEnvironment";
+
+ private static readonly string[] UsualNames = ["Development", "Staging", "Production"];
+
+ public static IHost CheckBusEnvironment(this IHost host)
+ {
+ using var scope = host.Services.CreateScope();
+ var services = scope.ServiceProvider;
+ var configuration = services.GetRequiredService();
+ var current = services.GetRequiredService().EnvironmentName;
+ var queuePrefix = services.GetRequiredService().QueuePrefix;
+
+ var problem = Check(services.GetRequiredService(), current, configuration[ConfirmSetting],
+ () => FormerName(configuration.GetConnectionString("RabbitMQ"), queuePrefix, current),
+ services.GetRequiredService>())
+ .GetAwaiter().GetResult();
+ if (problem is not null) host.Services.GetRequiredService().Add(problem);
+ return host;
+ }
+
+ ///
+ /// For a Bitween that ran before names were recorded: the name its queues were made under.
+ ///
+ /// The startup problem, or null when this name may run.
+ public static async Task Check(BitweenDbContext db, string current, string confirmed,
+ Func> formerName, ILogger logger)
+ {
+ var row = await db.Set().SingleOrDefaultAsync(s => s.Id == RecordedKey);
+ var recorded = row?.Value;
+
+ // A new installation has nothing to strand. One already in use tells by its queues.
+ if (recorded is null && await db.Set().AnyAsync())
+ {
+ try
+ {
+ recorded = await formerName();
+ }
+ catch (Exception ex)
+ {
+ // Not left to a later start: by then the bus has made queues under the new name, and they
+ // would be all that start finds. Without the broker the bus can't run anyway.
+ logger.LogWarning(ex, "Could not reach RabbitMQ to tell which environment this Bitween ran as before.");
+ return "RabbitMQ could not be reached to tell which ASPNETCORE_ENVIRONMENT this Bitween's queues were " +
+ $"made under ({ex.GetBaseException().Message}). Check ConnectionStrings:RabbitMQ and that the " +
+ "broker is up.";
+ }
+ }
+
+ if (recorded is not null && !Same(recorded, current) && !Same(confirmed, current))
+ return $"ASPNETCORE_ENVIRONMENT is {current}, but this Bitween's RabbitMQ queues are named after " +
+ $"{recorded}, which it ran with before. With the new name it would read from new queues, " +
+ "while other applications on this RabbitMQ keep sending to the old ones. If the change is a " +
+ $"mistake, set ASPNETCORE_ENVIRONMENT (the chart's global.environment) back to {recorded}. If " +
+ $"every application on this RabbitMQ moved to {current} on purpose, set " +
+ $"Bitween__ConfirmBusEnvironment={current}.";
+
+ if (Same(row?.Value, current)) return null;
+
+ if (row is null) db.Add(new Setting { Id = RecordedKey, Value = current });
+ else row.Value = current;
+ try
+ {
+ await db.SaveChangesAsync();
+ logger.LogInformation("Bitween's RabbitMQ queues are named after the environment {Environment}.", current);
+ }
+ catch (DbUpdateException ex)
+ {
+ // Another node starting at the same moment recorded it first, with the same name.
+ logger.LogWarning(ex, "The bus environment was recorded by another node.");
+ }
+
+ return null;
+ }
+
+ private static async Task FormerName(string connectionString, string queuePrefix, string current)
+ {
+ await using var broker = await BrokerQueues.ConnectAsync(connectionString);
+ foreach (var name in UsualNames.Prepend(current).Distinct(StringComparer.OrdinalIgnoreCase))
+ {
+ // The lane of exchanges without a work group, which every Bitween since work groups has;
+ // before them, the queue of exchanges from API calls.
+ if (await broker.InspectAsync(BrokerQueues.QueueName(name, queuePrefix, nameof(XchangeService),
+ WorkGroup.None.GetBusMessageName())) is not null ||
+ await broker.InspectAsync(BrokerQueues.QueueName(name, queuePrefix, nameof(XchangeService),
+ nameof(ApiXchangeCreatedEvent))) is not null)
+ return name;
+ }
+
+ // Nothing under any of them, so nothing to leave behind.
+ return current;
+ }
+
+ // The bus lowercases the name in every exchange and queue.
+ private static bool Same(string a, string b) => string.Equals(a, b, StringComparison.OrdinalIgnoreCase);
+}
diff --git a/SW.Bitween.Api/Services/LegacyEventQueues.cs b/SW.Bitween.Api/Services/LegacyEventQueues.cs
new file mode 100644
index 00000000..90ae0104
--- /dev/null
+++ b/SW.Bitween.Api/Services/LegacyEventQueues.cs
@@ -0,0 +1,93 @@
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using SW.Bitween.Domain;
+
+namespace SW.Bitween.Services;
+
+///
+/// The five queues named after exchange events, which a Bitween before work groups (January 2026)
+/// published to. Nothing publishes to them now, but an exchange waiting in one when its Bitween was
+/// upgraded runs only if it is read. Each start looks for them: one with something in it is read
+/// until it's empty, and an empty one nobody reads is removed. A setting used to switch this on, and
+/// an upgrade that didn't know about it left those exchanges behind.
+///
+public class LegacyEventQueues
+{
+ public static readonly string[] Names =
+ [
+ nameof(ApiXchangeCreatedEvent), nameof(InternalXchangeCreatedEvent), nameof(ReceivingXchangeCreatedEvent),
+ nameof(AggregateXchangeCreatedEvent), nameof(XchangeResultCreatedEvent)
+ ];
+
+ private readonly List _toDrain = [];
+
+ /// The ones reads from on this start.
+ public IReadOnlyList ToDrain => _toDrain;
+
+ /// Looks for them, before the bus starts and would create any it was told to read.
+ public async Task FindAsync(BrokerQueues broker, string environment, string queuePrefix, ILogger logger)
+ {
+ foreach (var name in Names)
+ {
+ var queue = BrokerQueues.QueueName(environment, queuePrefix, nameof(XchangeService), name);
+ var main = await broker.InspectAsync(queue);
+ if (main is null) continue;
+
+ // A failed message waits out its delay in the retry queue, and then goes back to the main one.
+ var retry = await broker.InspectAsync($"{queue}.retry");
+ if (main.MessageCount > 0 || main.ConsumerCount > 0 || retry?.MessageCount > 0)
+ {
+ _toDrain.Add(name);
+ logger.LogInformation("Reading {Queue}, left by a version of Bitween before work groups, until it is empty.",
+ queue);
+ continue;
+ }
+
+ await broker.DeleteIfIdleAsync(queue);
+ if (retry is not null) await broker.DeleteIfIdleAsync($"{queue}.retry");
+ // What failed for good stays where the queue's dead letters can still be looked at.
+ if (await broker.InspectAsync($"{queue}.bad") is { MessageCount: 0 })
+ await broker.DeleteIfIdleAsync($"{queue}.bad");
+ logger.LogInformation("Removed {Queue}, left empty by a version of Bitween before work groups.", queue);
+ }
+ }
+}
+
+public static class LegacyEventQueuesHostExtensions
+{
+ ///
+ /// Runs just before the bus starts. An unreachable broker is looked at again on the next start;
+ /// nothing in those queues goes anywhere meanwhile.
+ ///
+ public static IHost FindLegacyEventQueues(this IHost host)
+ {
+ using var scope = host.Services.CreateScope();
+ var services = scope.ServiceProvider;
+ var logger = services.GetRequiredService>();
+ try
+ {
+ FindAsync().GetAwaiter().GetResult();
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Could not reach RabbitMQ to look for queues left by a version of Bitween " +
+ "before work groups; they are looked for again on the next start.");
+ }
+
+ return host;
+
+ async Task FindAsync()
+ {
+ await using var broker = await BrokerQueues.ConnectAsync(
+ services.GetRequiredService().GetConnectionString("RabbitMQ"));
+ await services.GetRequiredService().FindAsync(broker,
+ services.GetRequiredService().EnvironmentName,
+ services.GetRequiredService().QueuePrefix, logger);
+ }
+ }
+}
diff --git a/SW.Bitween.Api/Services/SecretColumnEncryptionPass.cs b/SW.Bitween.Api/Services/SecretColumnEncryptionPass.cs
index 154cead7..8dc4dc57 100644
--- a/SW.Bitween.Api/Services/SecretColumnEncryptionPass.cs
+++ b/SW.Bitween.Api/Services/SecretColumnEncryptionPass.cs
@@ -41,6 +41,10 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
return;
}
+ // Re-saving a row writes what it read, and with the key lost that is empty: the values the
+ // lost key encrypted would be gone for good, rather than readable should it be found.
+ if (SecretColumnCipher.KeyLost) return;
+
try
{
await Task.Delay(Delay, stoppingToken);
diff --git a/SW.Bitween.Api/Services/SeededAdministrator.cs b/SW.Bitween.Api/Services/SeededAdministrator.cs
index bc6010a9..cc1fc895 100644
--- a/SW.Bitween.Api/Services/SeededAdministrator.cs
+++ b/SW.Bitween.Api/Services/SeededAdministrator.cs
@@ -26,11 +26,11 @@ public static class SeededAdministrator
public static IHost SecureSeededAdministrator(this IHost host)
{
using var scope = host.Services.CreateScope();
- Secure(
+ host.Services.GetRequiredService().Check(() => Secure(
scope.ServiceProvider.GetRequiredService(),
scope.ServiceProvider.GetRequiredService()["Bitween:InitialAdminPassword"],
scope.ServiceProvider.GetRequiredService>())
- .GetAwaiter().GetResult();
+ .GetAwaiter().GetResult());
return host;
}
@@ -54,14 +54,14 @@ public static async Task Secure(BitweenDbContext dbContext, string initialPasswo
if (await dbContext.Set().AnyAsync(a => a.Id != Id && !a.Deleted))
return;
- throw new InvalidOperationException(
+ throw new StartupProblemException(
"This is a new installation and Bitween:InitialAdminPassword is not configured. Set it " +
$"(env: Bitween__InitialAdminPassword) to the password for the administrator, {admin.Email}. " +
"It is read on startup only while that account still has the default password.");
}
if (initialPassword == PublishedPassword)
- throw new InvalidOperationException(
+ throw new StartupProblemException(
"Bitween:InitialAdminPassword is the default password, which is published in a public " +
"repository. Choose a different one.");
@@ -69,7 +69,7 @@ public static async Task Secure(BitweenDbContext dbContext, string initialPasswo
policy.RuleFor(p => p).Password().OverridePropertyName("InitialAdminPassword");
var result = policy.Validate(initialPassword);
if (!result.IsValid)
- throw new InvalidOperationException(
+ throw new StartupProblemException(
"Bitween:InitialAdminPassword does not meet the password policy: " +
string.Join(" ", result.Errors.Select(e => e.ErrorMessage)));
diff --git a/SW.Bitween.Api/Services/Settings/SettingsCatalog.cs b/SW.Bitween.Api/Services/Settings/SettingsCatalog.cs
index 0eb07ddd..3a6b4fa5 100644
--- a/SW.Bitween.Api/Services/Settings/SettingsCatalog.cs
+++ b/SW.Bitween.Api/Services/Settings/SettingsCatalog.cs
@@ -400,10 +400,6 @@ public static class SettingsCatalog
"How many messages of a lane run at once on each node when nothing more specific says: the ungrouped lane and the bus's other consumers. A work group sets its own. Unset, it is the default prefetch.",
SettingKind.Number, t => t.Bitween.ResolvedBusDefaultConcurrency.ToString(CultureInfo.InvariantCulture)),
- View("Bitween.ConsumeLegacyEventMessages", "Messaging", "Consume legacy event messages",
- "Whether this instance also drains the five queues named after exchange events, which an older Bitween published to before work groups existed. Nothing publishes to them today, so this is only for finishing off messages left behind by an upgrade.",
- SettingKind.Boolean, t => Str(t.Bitween.ConsumeLegacyEventMessages)),
-
Presence("Bitween.RabbitMqManagementUrl", "Messaging", "RabbitMQ management URL",
"The management API queue health is read from. All three management values are needed before queue depths can be shown.",
t => t.Bitween.RabbitMqManagementUrl),
diff --git a/SW.Bitween.Api/Services/Settings/SettingsHostExtensions.cs b/SW.Bitween.Api/Services/Settings/SettingsHostExtensions.cs
index 46d66169..c7c551b1 100644
--- a/SW.Bitween.Api/Services/Settings/SettingsHostExtensions.cs
+++ b/SW.Bitween.Api/Services/Settings/SettingsHostExtensions.cs
@@ -25,6 +25,7 @@ public static IHost ApplyStoredSettings(this IHost host)
settings.ImportMissing(dbContext).GetAwaiter().GetResult();
settings.Reload(dbContext).GetAwaiter().GetResult();
settings.RememberLegacyDocumentPrefix(dbContext).GetAwaiter().GetResult();
+ settings.WarnAboutIgnoredConfiguration(dbContext).GetAwaiter().GetResult();
}
catch (Exception ex)
{
diff --git a/SW.Bitween.Api/Services/Settings/SettingsService.cs b/SW.Bitween.Api/Services/Settings/SettingsService.cs
index 30c5da65..36198759 100644
--- a/SW.Bitween.Api/Services/Settings/SettingsService.cs
+++ b/SW.Bitween.Api/Services/Settings/SettingsService.cs
@@ -155,6 +155,54 @@ public async Task Reload(BitweenDbContext dbContext)
}
}
+ ///
+ /// Names each setting configuration gives a value other than the one stored. The stored one is
+ /// used, so whoever deploys a changed value would otherwise see nothing happen. Startup only.
+ ///
+ public async Task WarnAboutIgnoredConfiguration(BitweenDbContext dbContext)
+ {
+ var rows = await dbContext.Set().AsNoTracking()
+ .ToDictionaryAsync(s => s.Id, s => s.Value, StringComparer.OrdinalIgnoreCase);
+
+ foreach (var definition in SettingsCatalog.All)
+ {
+ if (!definition.Stored || !rows.TryGetValue(definition.Key, out var stored)) continue;
+
+ var configured = _configured.GetValueOrDefault(definition.Key) ?? string.Empty;
+ // Configuration that sets nothing reads as the product default.
+ if (configured == DefaultOf(definition)) continue;
+
+ string current;
+ try
+ {
+ current = Normalized(definition, definition.Secret ? _protector.Unprotect(stored) : stored);
+ }
+ catch
+ {
+ // Unreadable or invalid: Reload has already said so.
+ continue;
+ }
+
+ if (Normalized(definition, configured) == current) continue;
+
+ if (definition.Secret)
+ _logger.LogWarning("{Key} is set in configuration, but the Settings page holds a different value, " +
+ "which is the one used. Change it on the Settings page.", definition.Key);
+ else
+ _logger.LogWarning("{Key} is {Configured} in configuration, but the Settings page holds {Stored}, " +
+ "which is the one used. Change it on the Settings page.",
+ definition.Key, configured, current);
+ }
+ }
+
+ // As the options hold it, so "true" and "True" are the same value.
+ private static string Normalized(SettingDefinition definition, string value)
+ {
+ var target = new SettingsTarget(new BitweenOptions(), new ThemeOptions());
+ definition.Write(target, value);
+ return definition.Read(target) ?? string.Empty;
+ }
+
///
/// Writes the first time this version starts - before anyone
/// could have changed the prefix, so it's the one every earlier exchange was written under. Runs
diff --git a/SW.Bitween.Api/Services/StartupProblems.cs b/SW.Bitween.Api/Services/StartupProblems.cs
new file mode 100644
index 00000000..f560d3bc
--- /dev/null
+++ b/SW.Bitween.Api/Services/StartupProblems.cs
@@ -0,0 +1,41 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace SW.Bitween.Services;
+
+///
+/// A reason Bitween must not start, worded as what to change. A check throws it and
+/// collects it, so the checks after it still run.
+///
+public class StartupProblemException(string message) : InvalidOperationException(message);
+
+///
+/// Every reason this start is refused. They are logged together, so whoever deploys fixes all of
+/// them in one redeploy rather than meeting them one restart at a time.
+///
+public class StartupProblems
+{
+ private readonly List _all = [];
+
+ public bool Any => _all.Count > 0;
+
+ public void Add(string problem) => _all.Add(problem);
+
+ /// Runs , recording the problem it reports instead of stopping.
+ public void Check(Action check)
+ {
+ try
+ {
+ check();
+ }
+ catch (StartupProblemException ex)
+ {
+ Add(ex.Message);
+ }
+ }
+
+ public override string ToString() =>
+ "Bitween can't start until these are fixed:" +
+ string.Concat(_all.Select((problem, i) => $"{Environment.NewLine} {i + 1}. {problem}"));
+}
diff --git a/SW.Bitween.Api/Services/StorageCheck.cs b/SW.Bitween.Api/Services/StorageCheck.cs
new file mode 100644
index 00000000..ff08cecb
--- /dev/null
+++ b/SW.Bitween.Api/Services/StorageCheck.cs
@@ -0,0 +1,56 @@
+using System;
+using System.IO;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using SW.PrimitiveTypes;
+
+namespace SW.Bitween.Services;
+
+///
+/// Whether Bitween can write its files and read them back. Every exchange writes to storage, and a
+/// wrong bucket or credential used to show up only as the first exchange failing.
+///
+public static class StorageCheck
+{
+ private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(30);
+
+ /// Runs after the stored settings are applied, which hold the document prefix.
+ public static IHost CheckStorage(this IHost host)
+ {
+ using var scope = host.Services.CreateScope();
+ var services = scope.ServiceProvider;
+ var options = services.GetRequiredService();
+ var problem = Check(services.GetRequiredService(), options.DocumentPrefix,
+ $"{options.StorageProvider}, bucket {services.GetRequiredService()["CloudFiles:BucketName"]}")
+ .GetAwaiter().GetResult();
+ if (problem is not null) host.Services.GetRequiredService().Add(problem);
+ return host;
+ }
+
+ /// Which storage, for the message.
+ /// The startup problem, or null when the file came back as it was written.
+ public static async Task Check(ICloudFilesService cloudFiles, string documentPrefix, string storage)
+ {
+ // The file the settings page writes to tell whether the bucket is open to everyone.
+ var key = $"{documentPrefix}/{StorageAccess.ProbeName}";
+ try
+ {
+ await cloudFiles.WriteTextAsync(StorageAccess.ProbeText,
+ new WriteFileSettings { Key = key, ContentType = "text/plain" }).WaitAsync(Timeout);
+ await using var stream = await cloudFiles.OpenReadAsync(key).WaitAsync(Timeout);
+ using var reader = new StreamReader(stream);
+ if (await reader.ReadToEndAsync().WaitAsync(Timeout) == StorageAccess.ProbeText) return null;
+ return Problem(storage, "the file read back is not the one written.");
+ }
+ catch (Exception ex)
+ {
+ return Problem(storage, ex is TimeoutException ? $"no answer within {Timeout.TotalSeconds:0} seconds." : ex.Message);
+ }
+ }
+
+ public static string Problem(string storage, string reason) =>
+ $"Bitween can't write its files to storage ({storage}) and read them back: {reason} Check " +
+ "Bitween:StorageProvider and the CloudFiles settings: the address, the bucket and the credentials.";
+}
diff --git a/SW.Bitween.Api/Services/StoredSecretsKey.cs b/SW.Bitween.Api/Services/StoredSecretsKey.cs
new file mode 100644
index 00000000..a683658f
--- /dev/null
+++ b/SW.Bitween.Api/Services/StoredSecretsKey.cs
@@ -0,0 +1,87 @@
+using System;
+using System.Linq;
+using System.Threading.Tasks;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using SW.Bitween.Domain;
+using SW.Bitween.Domain.DataSources;
+
+namespace SW.Bitween.Services;
+
+///
+/// Whether the credentials this database holds can be read with the configured
+/// Bitween:SettingsEncryptionKey. A missing or wrong key used to start without complaint and
+/// then fail every exchange that needed a partner's or a subscription's settings.
+///
+public static class StoredSecretsKey
+{
+ public const string KeyLostSetting = "Bitween:SettingsEncryptionKeyLost";
+
+ // Every row, not a sample: a key rotation stopped half way leaves older rows under the old key.
+ // These are configuration tables, which the encryption pass reads whole on every start too.
+ private const int BatchSize = 100;
+
+ ///
+ /// Records a startup problem when they can't be read. Runs after the migrations, before anything
+ /// that reads those tables.
+ ///
+ /// Whether they can be read, so the startup steps that read them can run.
+ public static bool CanReadStoredSecrets(this IHost host)
+ {
+ using var scope = host.Services.CreateScope();
+ var logger = scope.ServiceProvider.GetRequiredService>();
+
+ if (SecretColumnCipher.KeyLost)
+ {
+ logger.LogWarning("{Setting} is on: credentials that can't be read with the current key show as " +
+ "empty until they are entered again. Remove it once they are.", KeyLostSetting);
+ return true;
+ }
+
+ if (AllReadable(scope.ServiceProvider.GetRequiredService()).GetAwaiter().GetResult())
+ return true;
+
+ host.Services.GetRequiredService().Add(
+ (SecretColumnCipher.Enabled
+ ? "Bitween:SettingsEncryptionKey is not the key this database's credentials were encrypted with. "
+ : "Bitween:SettingsEncryptionKey is not set, but this database holds credentials encrypted with one. ") +
+ "Set it (env: Bitween__SettingsEncryptionKey) to the key used before: a Helm deployment still has " +
+ "it in an earlier release, shown by `helm history ` and `helm get values " +
+ "--revision `. Only if it is lost for good, set Bitween__SettingsEncryptionKeyLost=true: " +
+ "partner, subscription and data source settings it encrypted then show as empty until " +
+ "they are entered again.");
+ return false;
+ }
+
+ public static async Task AllReadable(BitweenDbContext db) =>
+ await Readable(db) && await Readable(db) &&
+ await Readable(db) && await Readable(db) &&
+ await Readable(db) && await Readable(db) &&
+ await Readable(db) && await Readable(db) &&
+ await Readable(db);
+
+ private static async Task Readable(BitweenDbContext db) where T : class
+ {
+ var key = db.Model.FindEntityType(typeof(T))!.FindPrimaryKey()!.Properties[0].Name;
+ try
+ {
+ for (var skip = 0; ; skip += BatchSize)
+ {
+ var batch = await db.Set().AsNoTracking()
+ .OrderBy(e => EF.Property