Files
MeterVault/src/Infrastructure/Backup/ExportService.cs
T
schmidt.florian e23df37a3f
ci / build-test (push) Successful in 1m12s
Connectors: allow secrets to be entered in the UI, encrypted at rest
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
2026-07-18 15:41:52 +02:00

182 lines
7.9 KiB
C#

using System.Text.Json;
using MeterVault.Core.Domain;
using MeterVault.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace MeterVault.Infrastructure.Backup;
/// <summary>
/// Exports/imports the configuration snapshot (SDD §10). Import restores into an empty instance,
/// remapping surrogate ids so foreign keys stay consistent regardless of the original ids.
/// </summary>
public sealed class ExportService(MeterVaultDbContext db)
{
private static readonly JsonSerializerOptions Json = new()
{
WriteIndented = true,
ReferenceHandler = System.Text.Json.Serialization.ReferenceHandler.IgnoreCycles,
};
private readonly MeterVaultDbContext _db = db;
/// <summary>
/// Strips encrypted connector secrets from an export. They are ciphertext, not plaintext, so
/// this is not a §6.4 requirement — but the export exists for portability (§9), and ciphertext
/// is bound to the originating instance's key ring, so it is useless anywhere it could be
/// restored and merely widens the blast radius if the key ring also leaks. Env-var references
/// survive: they name a variable and reveal nothing. Restoring means re-entering the secrets.
/// </summary>
private static List<IngestionEndpoint> RedactSecrets(List<IngestionEndpoint> endpoints)
{
foreach (var endpoint in endpoints)
{
endpoint.Config = endpoint.Type == EndpointType.HomeAssistant
? (Ingestion.HaEndpointConfig.Parse(endpoint.Config) with { TokenEnc = null }).ToJson()
: (Ingestion.EndpointConfig.Parse(endpoint.Config) with { PasswordEnc = null }).ToJson();
}
return endpoints;
}
public async Task<string> ExportJsonAsync(CancellationToken cancellationToken = default)
{
// Load without navigation includes so serialization is a clean, cycle-free tree.
var document = new ExportDocument
{
EnergyTypes = await _db.EnergyTypes.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false),
CostCategories = await _db.CostCategories.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false),
Meters = await _db.Meters.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false),
IngestionEndpoints = RedactSecrets(
await _db.IngestionEndpoints.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false)),
MeterSources = await _db.MeterSources.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false),
Tanks = await _db.Tanks.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false),
Tariffs = await _db.Tariffs.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false),
CostCategoryMembers = await _db.CostCategoryMembers.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false),
ManualCosts = await _db.ManualCosts.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false),
MeterEvents = await _db.MeterEvents.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false),
AppSettings = await _db.AppSettings.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false),
};
return JsonSerializer.Serialize(document, Json);
}
public async Task ImportJsonAsync(string json, CancellationToken cancellationToken = default)
{
var doc = JsonSerializer.Deserialize<ExportDocument>(json, Json)
?? throw new InvalidOperationException("Empty or invalid export document.");
await using var tx = await _db.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
var typeMap = new Dictionary<short, short>();
foreach (var type in doc.EnergyTypes)
{
var old = type.Id;
type.Id = 0;
_db.EnergyTypes.Add(type);
await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
typeMap[old] = type.Id;
}
var categoryMap = await InsertMappedAsync(doc.CostCategories, c => c.Id, (c, _) => c.Id = 0, cancellationToken).ConfigureAwait(false);
var meterMap = new Dictionary<int, int>();
foreach (var meter in doc.Meters)
{
var old = meter.Id;
meter.Id = 0;
meter.EnergyTypeId = typeMap.GetValueOrDefault(meter.EnergyTypeId, meter.EnergyTypeId);
meter.EnergyType = null;
_db.Meters.Add(meter);
await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
meterMap[old] = meter.Id;
}
var endpointMap = await InsertMappedAsync(doc.IngestionEndpoints, e => e.Id, (e, _) => e.Id = 0, cancellationToken).ConfigureAwait(false);
foreach (var source in doc.MeterSources)
{
source.Id = 0;
source.MeterId = meterMap.GetValueOrDefault(source.MeterId, source.MeterId);
source.EndpointId = source.EndpointId is { } eid ? endpointMap.GetValueOrDefault(eid, eid) : null;
source.Meter = null;
source.Endpoint = null;
_db.MeterSources.Add(source);
}
foreach (var tank in doc.Tanks)
{
tank.Id = 0;
tank.MeterId = meterMap.GetValueOrDefault(tank.MeterId, tank.MeterId);
tank.Meter = null;
_db.Tanks.Add(tank);
}
foreach (var tariff in doc.Tariffs)
{
tariff.Id = 0;
tariff.ScopeId = tariff.ScopeType switch
{
TariffScope.Meter when tariff.ScopeId is { } id => meterMap.GetValueOrDefault(id, id),
TariffScope.EnergyType when tariff.ScopeId is { } id => typeMap.GetValueOrDefault((short)id, (short)id),
_ => tariff.ScopeId,
};
_db.Tariffs.Add(tariff);
}
foreach (var member in doc.CostCategoryMembers)
{
member.Id = 0;
member.CategoryId = categoryMap.GetValueOrDefault(member.CategoryId, member.CategoryId);
member.MeterId = member.MeterId is { } mid ? meterMap.GetValueOrDefault(mid, mid) : null;
member.EnergyTypeId = member.EnergyTypeId is { } tid ? typeMap.GetValueOrDefault(tid, tid) : null;
member.Category = null;
_db.CostCategoryMembers.Add(member);
}
foreach (var cost in doc.ManualCosts)
{
cost.Id = 0;
cost.CategoryId = cost.CategoryId is { } cid ? categoryMap.GetValueOrDefault(cid, cid) : null;
cost.MeterId = cost.MeterId is { } mid ? meterMap.GetValueOrDefault(mid, mid) : null;
cost.ImportBatchId = null; // the original import batch isn't part of the export
_db.ManualCosts.Add(cost);
}
foreach (var meterEvent in doc.MeterEvents)
{
meterEvent.Id = 0;
meterEvent.MeterId = meterMap.GetValueOrDefault(meterEvent.MeterId, meterEvent.MeterId);
meterEvent.ImportBatchId = null;
_db.MeterEvents.Add(meterEvent);
}
foreach (var setting in doc.AppSettings)
{
if (!await _db.AppSettings.AnyAsync(s => s.Key == setting.Key, cancellationToken).ConfigureAwait(false))
{
_db.AppSettings.Add(setting);
}
}
await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
await tx.CommitAsync(cancellationToken).ConfigureAwait(false);
}
private async Task<Dictionary<int, int>> InsertMappedAsync<T>(
List<T> items, Func<T, int> getId, Action<T, int> clearId, CancellationToken cancellationToken)
where T : class
{
var map = new Dictionary<int, int>();
foreach (var item in items)
{
var old = getId(item);
clearId(item, 0);
_db.Add(item);
await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
map[old] = getId(item);
}
return map;
}
}