@page "/admin/connectors" @inject Microsoft.EntityFrameworkCore.IDbContextFactory DbFactory @inject MeterVault.Infrastructure.Ingestion.HaConnectionTester HaTester @inject MeterVault.Infrastructure.Security.SecretProtector Secrets @inject ISnackbar Snackbar @inject IDialogService DialogService @using Microsoft.EntityFrameworkCore @using MeterVault.Infrastructure.Ingestion @using MudBlazor MeterVault — Connectors
Connectors Add connector
Secrets are never stored here. Credentials/tokens are referenced by the name of an environment variable (or Docker secret) resolved at runtime. @if (_endpoints is null) { } else { Name Type Enabled Last status Last seen Actions @context.Name @context.Type @(context.IsEnabled ? "yes" : "no") @(context.LastStatus ?? "—") @(context.LastSeenAt?.ToString("yyyy-MM-dd HH:mm") ?? "—") @if (_endpoints.Count == 0) { No connectors yet. Add an MQTT broker or a Home Assistant connection. } } @(_working.Id == 0 ? "New connector" : $"Edit {_working.Name}") @foreach (var type in Enum.GetValues()) { @type } @if (_working.Type == EndpointType.HomeAssistant) { @if (_working.UseDirectToken) { Encrypted before it is stored; database dumps and JSON exports carry nothing usable. } else { The variable's name, not the token. Set it on the server and restart the app. } On: subscribe to state changes and ingest in real time. Off: REST poll on each source's interval. Test connection @if (_testing) { } @if (_testResult is not null) { @_testResult.Message } } else { @if (_working.UseDirectCredentials) { Encrypted before it is stored; database dumps and JSON exports carry nothing usable. } else { } } Cancel Save @code { private List? _endpoints; private bool _editOpen; private bool _testing; private HaTestResult? _testResult; private EditModel _working = new(); private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true }; protected override Task OnInitializedAsync() => LoadAsync(); private async Task LoadAsync() { await using var db = await DbFactory.CreateDbContextAsync(); _endpoints = await db.IngestionEndpoints.AsNoTracking().OrderBy(e => e.Name).ToListAsync(); } private void OpenEdit(IngestionEndpoint? endpoint) { _testResult = null; if (endpoint is null) { _working = new EditModel(); } else if (endpoint.Type == EndpointType.HomeAssistant) { var ha = HaEndpointConfig.Parse(endpoint.Config); _working = new EditModel { Id = endpoint.Id, Type = endpoint.Type, Name = endpoint.Name, IsEnabled = endpoint.IsEnabled, BaseUrl = ha.BaseUrl, TokenEnv = ha.TokenEnv, UseWebSocket = ha.UseWebSocket, // Carry the ciphertext through untouched and never send the secret to the browser: // the field stays blank and only a typed value replaces what is stored. TokenEnc = ha.TokenEnc, UseDirectToken = !string.IsNullOrWhiteSpace(ha.TokenEnc), // The host the stored token was saved against; a stored token is never sent anywhere else. SavedBaseUrl = ha.BaseUrl, }; } else { var mqtt = EndpointConfig.Parse(endpoint.Config); _working = new EditModel { Id = endpoint.Id, Type = endpoint.Type, Name = endpoint.Name, IsEnabled = endpoint.IsEnabled, Host = mqtt.Host, Port = mqtt.Port, Tls = mqtt.Tls, UsernameEnv = mqtt.UsernameEnv, PasswordEnv = mqtt.PasswordEnv, Username = mqtt.Username, PasswordEnc = mqtt.PasswordEnc, UseDirectCredentials = !string.IsNullOrWhiteSpace(mqtt.Username) || !string.IsNullOrWhiteSpace(mqtt.PasswordEnc), ExtraTopics = string.Join(", ", mqtt.ExtraTopics), }; } _editOpen = true; } private async Task TestHaAsync() { _testing = true; _testResult = null; try { // Test what the connector would actually use — including a token typed but not yet // saved, so a bad token is caught before it is stored. if (_working.UseDirectToken) { if (!string.IsNullOrWhiteSpace(_working.Token)) { _testResult = await HaTester.TestAsync(_working.BaseUrl, _working.Token, _working.TestEntityId); } else if (!SameOrigin(_working.BaseUrl, _working.SavedBaseUrl)) { // Storing the token encrypted means the UI can decrypt something the operator // can no longer read. Sending it to a Base URL edited in this dialog would turn // "Test connection" into an exfiltration primitive — point it at any host and the // token arrives as a Bearer header. A stored secret only ever goes to the origin // it was saved for; testing elsewhere means typing the token again. _testResult = new HaTestResult(false, "Base URL differs from the saved one. Re-enter the token to test against a different host — " + "a stored token is only sent to the host it was saved for."); } else if (Secrets.TryUnprotect(_working.TokenEnc, out var stored) && stored is { Length: > 0 }) { _testResult = await HaTester.TestAsync(_working.BaseUrl, stored, _working.TestEntityId); } else { _testResult = new HaTestResult(false, "Enter a token first."); } } else if (string.IsNullOrWhiteSpace(_working.TokenEnv)) { _testResult = new HaTestResult(false, "Name the environment variable holding the token, or switch on \"Enter the token here\"."); } else if (Environment.GetEnvironmentVariable(_working.TokenEnv) is not { Length: > 0 } envToken) { _testResult = new HaTestResult(false, $"Environment variable '{_working.TokenEnv}' is not set on the server. Set it and restart the app, " + "or switch on \"Enter the token here\" to store the token directly."); } else { _testResult = await HaTester.TestAsync(_working.BaseUrl, envToken, _working.TestEntityId); } } finally { _testing = false; } } private async Task SaveAsync() { if (string.IsNullOrWhiteSpace(_working.Name)) { Snackbar.Add("Name is required.", Severity.Warning); return; } if (_working.Type == EndpointType.HomeAssistant && _working.UseDirectToken && string.IsNullOrWhiteSpace(_working.Token) && !_working.HasStoredToken) { Snackbar.Add("Enter the token, or switch off \"Enter the token here\" and name an env var.", Severity.Warning); return; } var config = _working.Type == EndpointType.HomeAssistant ? new HaEndpointConfig { BaseUrl = Trim(_working.BaseUrl), UseWebSocket = _working.UseWebSocket, // Exactly one storage form survives a save: switching modes clears the other, so a // stale token cannot linger and silently win at resolution time. TokenEnv = _working.UseDirectToken ? null : Trim(_working.TokenEnv), TokenEnc = _working.UseDirectToken ? ProtectOrKeep(_working.Token, _working.TokenEnc) : null, }.ToJson() : new EndpointConfig { Host = string.IsNullOrWhiteSpace(_working.Host) ? "localhost" : _working.Host.Trim(), Port = _working.Port, Tls = _working.Tls, UsernameEnv = _working.UseDirectCredentials ? null : Trim(_working.UsernameEnv), PasswordEnv = _working.UseDirectCredentials ? null : Trim(_working.PasswordEnv), Username = _working.UseDirectCredentials ? Trim(_working.Username) : null, PasswordEnc = _working.UseDirectCredentials ? ProtectOrKeep(_working.Password, _working.PasswordEnc) : null, ExtraTopics = SplitTopics(_working.ExtraTopics), }.ToJson(); await using var db = await DbFactory.CreateDbContextAsync(); if (_working.Id == 0) { db.IngestionEndpoints.Add(new IngestionEndpoint { Type = _working.Type, Name = _working.Name.Trim(), Config = config, IsEnabled = _working.IsEnabled, }); } else { var existing = await db.IngestionEndpoints.FirstAsync(e => e.Id == _working.Id); existing.Type = _working.Type; existing.Name = _working.Name.Trim(); existing.Config = config; existing.IsEnabled = _working.IsEnabled; } await db.SaveChangesAsync(); _editOpen = false; Snackbar.Add("Saved.", Severity.Success); await LoadAsync(); } private async Task DeleteAsync(IngestionEndpoint endpoint) { await using var db = await DbFactory.CreateDbContextAsync(); var sourceCount = await db.MeterSources.CountAsync(s => s.EndpointId == endpoint.Id); // Routing is endpoint-scoped, so "unlinked" now means those sources stop ingesting entirely // rather than falling back to any broker. Say so plainly. var note = sourceCount > 0 ? $" {sourceCount} source(s) use it and will stop ingesting until reassigned to another connector." : ""; if (!await Confirm.DeleteAsync(DialogService, "Delete connector", $"Delete '{endpoint.Name}'?{note}")) { return; } await db.IngestionEndpoints.Where(e => e.Id == endpoint.Id).ExecuteDeleteAsync(); Snackbar.Add("Deleted.", Severity.Success); await LoadAsync(); } private static string? Trim(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); /// /// Whether two URLs address the same host. Compares scheme, host and port rather than the raw /// string, so a trailing slash or a path tweak does not force the token to be re-typed. Fails /// closed: anything unparsable counts as a different origin. /// private static bool SameOrigin(string? a, string? b) => Uri.TryCreate(a, UriKind.Absolute, out var left) && Uri.TryCreate(b, UriKind.Absolute, out var right) && string.Equals(left.Scheme, right.Scheme, StringComparison.OrdinalIgnoreCase) && string.Equals(left.Host, right.Host, StringComparison.OrdinalIgnoreCase) && left.Port == right.Port; /// /// Encrypts a newly typed secret, or keeps the stored ciphertext when the field was left blank. /// The plaintext is never sent to the browser, so blank means "unchanged", not "cleared". /// private string? ProtectOrKeep(string? typed, string? existingCiphertext) => string.IsNullOrWhiteSpace(typed) ? existingCiphertext : Secrets.Protect(typed); private static IReadOnlyList SplitTopics(string? csv) => string.IsNullOrWhiteSpace(csv) ? [] : [.. csv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)]; private sealed class EditModel { public int Id { get; set; } public EndpointType Type { get; set; } = EndpointType.HomeAssistant; public string Name { get; set; } = ""; public bool IsEnabled { get; set; } = true; // Home Assistant public string? BaseUrl { get; set; } public string? TokenEnv { get; set; } public bool UseWebSocket { get; set; } public string? TestEntityId { get; set; } /// True to store the token here (encrypted); false to name an env var. public bool UseDirectToken { get; set; } /// Typed token. Always blank on load — a stored secret is never sent to the browser. public string? Token { get; set; } /// Stored ciphertext, round-tripped so leaving blank keeps it. public string? TokenEnc { get; set; } /// Base URL as saved, so an edited one can be told from the token's own host. public string? SavedBaseUrl { get; set; } public bool HasStoredToken => !string.IsNullOrWhiteSpace(TokenEnc); // MQTT broker public string? Host { get; set; } = "localhost"; public int Port { get; set; } = 1883; public bool Tls { get; set; } public string? UsernameEnv { get; set; } public string? PasswordEnv { get; set; } public string? ExtraTopics { get; set; } public bool UseDirectCredentials { get; set; } /// Username entered directly — not a secret, so shown when editing. public string? Username { get; set; } public string? Password { get; set; } public string? PasswordEnc { get; set; } public bool HasStoredPassword => !string.IsNullOrWhiteSpace(PasswordEnc); } }