ci / build-test (push) Successful in 1m12s
Reference-only secrets (SDD §6.4) meant adding a connector required editing a file on the server and restarting the service. In practice that leads to the token being pasted into the env-var *name* field, which fails with "environment variable '<token>' is not set" and gives no hint what went wrong. Add a second storage form, chosen per connector: type the secret in and it is encrypted via ASP.NET Core data protection before it is stored. The env-var reference stays as an equal alternative — this widens the choice rather than replacing it. Exactly one form survives a save, so a stale secret cannot linger and silently win; EndpointSecret.Resolve is the single resolution path. The guarantee that matters is preserved: no plaintext in the database, so pg_dump and JSON exports carry nothing usable. The trust boundary is stated plainly in §6.4 — the key ring is on disk, so this protects against leaked database content, not an attacker who already has the host, which is the same boundary an env var has. Details worth noting: - Key ring defaults to /var/lib/metervault/keys, outside the app directory, because the LXC updater republishes /opt/metervault on every update. Docker gets a named volume. Overridable via MeterVault__DataProtectionKeyPath. - Undecryptable ciphertext (key ring lost) falls back rather than throwing: an ingestion worker on a timer should degrade, not crash. - The stored secret is never sent to the browser; a blank field means "unchanged", not "cleared". - MQTT usernames are stored as-is — §6.4 covers tokens and passwords, and encrypting a username would only blank the field on every edit. - ExportService drops *_enc values: bound to the originating key ring, so useless where an export would be restored. Expect to re-enter after a restore. - HaConnectionTester now takes a resolved token, so the admin UI can test a token that has been typed but not yet saved. SDD §6.4 and §9 updated to describe both forms rather than contradict the code. Claude-Session: https://claude.ai/code/session_01V6joyergfvVLFEizH1hJLd
155 lines
5.8 KiB
C#
155 lines
5.8 KiB
C#
using MeterVault.App.Api;
|
|
using MeterVault.App.Components;
|
|
using MeterVault.Infrastructure;
|
|
using MeterVault.Infrastructure.Options;
|
|
using MeterVault.Infrastructure.Persistence;
|
|
using Microsoft.AspNetCore.DataProtection;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using MudBlazor.Services;
|
|
using Serilog;
|
|
|
|
Log.Logger = new LoggerConfiguration()
|
|
.WriteTo.Console()
|
|
.CreateBootstrapLogger();
|
|
|
|
try
|
|
{
|
|
var builder = WebApplication.CreateBuilder(args);
|
|
|
|
builder.Host.UseSerilog((context, services, configuration) => configuration
|
|
.ReadFrom.Configuration(context.Configuration)
|
|
.ReadFrom.Services(services)
|
|
.Enrich.FromLogContext()
|
|
.WriteTo.Console());
|
|
|
|
builder.Services.Configure<MeterVaultOptions>(
|
|
builder.Configuration.GetSection(MeterVaultOptions.SectionName));
|
|
|
|
var connectionString = builder.Configuration.GetConnectionString("Default")
|
|
?? "Host=localhost;Port=5432;Database=metervault;Username=metervault;Password=metervault";
|
|
builder.Services.AddMeterVaultInfrastructure(connectionString);
|
|
|
|
// Key ring for connector secrets typed into the admin UI. It must outlive the app directory:
|
|
// the LXC updater republishes /opt/metervault on every update, so keys stored beside the
|
|
// binaries would be destroyed and every saved token would need re-entering. Override with
|
|
// MeterVault__DataProtectionKeyPath (Docker: point it at a mounted volume).
|
|
var keyPath = builder.Configuration["MeterVault:DataProtectionKeyPath"];
|
|
if (string.IsNullOrWhiteSpace(keyPath))
|
|
{
|
|
keyPath = OperatingSystem.IsWindows()
|
|
? Path.Combine(
|
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "MeterVault", "keys")
|
|
: "/var/lib/metervault/keys";
|
|
}
|
|
|
|
try
|
|
{
|
|
Directory.CreateDirectory(keyPath);
|
|
}
|
|
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
|
{
|
|
// Falling back beats refusing to boot, but say so plainly: on the fallback path an update
|
|
// that replaces the content root loses the keys, and stored secrets stop decrypting.
|
|
var fallback = Path.Combine(builder.Environment.ContentRootPath, "keys");
|
|
Log.Warning(ex,
|
|
"Cannot create data-protection key ring at {KeyPath}; falling back to {Fallback}. "
|
|
+ "Secrets entered in the admin UI will not survive a redeploy that replaces the content "
|
|
+ "root — set MeterVault__DataProtectionKeyPath to a writable persistent directory",
|
|
keyPath, fallback);
|
|
keyPath = fallback;
|
|
Directory.CreateDirectory(keyPath);
|
|
}
|
|
|
|
builder.Services.AddDataProtection()
|
|
.SetApplicationName("MeterVault")
|
|
.PersistKeysToFileSystem(new DirectoryInfo(keyPath));
|
|
|
|
var options = builder.Configuration.GetSection(MeterVaultOptions.SectionName).Get<MeterVaultOptions>()
|
|
?? new MeterVaultOptions();
|
|
if (options.EnableLiveIngestion)
|
|
{
|
|
builder.Services.AddMeterVaultIngestion();
|
|
}
|
|
|
|
builder.Services.AddMudServices();
|
|
builder.Services.AddRazorComponents()
|
|
.AddInteractiveServerComponents();
|
|
|
|
// Serialize/accept enums as strings on the REST API (e.g. event Type "Delivery").
|
|
builder.Services.ConfigureHttpJsonOptions(o =>
|
|
o.SerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter()));
|
|
|
|
builder.Services.AddEndpointsApiExplorer();
|
|
builder.Services.AddSwaggerGen(c =>
|
|
c.SwaggerDoc("v1", new() { Title = "MeterVault API", Version = "v1" }));
|
|
|
|
var app = builder.Build();
|
|
|
|
await MigrateDatabaseAsync(app).ConfigureAwait(false);
|
|
|
|
if (!app.Environment.IsDevelopment())
|
|
{
|
|
app.UseExceptionHandler("/Error", createScopeForErrors: true);
|
|
app.UseHsts();
|
|
}
|
|
|
|
app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true);
|
|
app.UseSerilogRequestLogging();
|
|
app.UseReverseProxyTrust();
|
|
// No HTTPS redirection: the app serves plain HTTP (port 8760) behind a reverse proxy
|
|
// that terminates TLS (SDD §10). HTTPS redirection here would break the container and proxy.
|
|
app.UseAntiforgery();
|
|
|
|
app.UseSwagger();
|
|
app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "MeterVault API v1"));
|
|
|
|
app.MapStaticAssets();
|
|
app.MapRazorComponents<App>()
|
|
.AddInteractiveServerRenderMode();
|
|
|
|
app.MapMeterVaultApi();
|
|
|
|
// Liveness/readiness probe for Gatus/Compose healthchecks (SDD §9).
|
|
app.MapGet("/healthz", () => Results.Ok(new { status = "ok" }));
|
|
|
|
await app.RunAsync().ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.Fatal(ex, "MeterVault terminated unexpectedly");
|
|
throw;
|
|
}
|
|
finally
|
|
{
|
|
await Log.CloseAndFlushAsync().ConfigureAwait(false);
|
|
}
|
|
|
|
static async Task MigrateDatabaseAsync(WebApplication app)
|
|
{
|
|
var options = app.Configuration
|
|
.GetSection(MeterVaultOptions.SectionName)
|
|
.Get<MeterVaultOptions>() ?? new MeterVaultOptions();
|
|
|
|
if (!options.RunMigrationsAtStartup)
|
|
{
|
|
return;
|
|
}
|
|
|
|
await using var scope = app.Services.CreateAsyncScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<MeterVaultDbContext>();
|
|
await db.Database.MigrateAsync().ConfigureAwait(false);
|
|
await DatabaseSeeder.SeedAsync(db).ConfigureAwait(false);
|
|
Log.Information("Database migrations applied and defaults seeded");
|
|
|
|
if (options.SeedReferenceData)
|
|
{
|
|
var importer = scope.ServiceProvider.GetRequiredService<MeterVault.Infrastructure.Import.ReferenceDataImporter>();
|
|
var dir = Path.Combine(AppContext.BaseDirectory, "sampledata");
|
|
await importer.LoadAsync(dir).ConfigureAwait(false);
|
|
Log.Information("Reference dataset ensured (SeedReferenceData=true)");
|
|
}
|
|
}
|
|
|
|
/// <summary>Exposed for WebApplicationFactory-based integration tests.</summary>
|
|
public partial class Program;
|