feat: Implement container logs functionality in InstancesViewModel
- Added properties for managing container logs, including log entries, service filters, and auto-refresh options. - Introduced commands for refreshing logs, toggling auto-refresh, and closing the logs panel. - Implemented log fetching logic with error handling and status messages. - Integrated log display in the InstancesView with a dedicated logs panel. feat: Enhance navigation to Instances page with auto-selection - Added method to navigate to the Instances page and auto-select an instance based on abbreviation. feat: Update SettingsViewModel to load and save Bitwarden configuration - Integrated Bitwarden configuration loading from IOptions and saving to appsettings.json. - Added properties for Bitwarden instance project ID and connection status. - Updated UI to reflect Bitwarden settings and connection status. feat: Add advanced options for instance creation - Introduced a new expander in CreateInstanceView for advanced options, including purging stale volumes. feat: Improve InstanceDetailsWindow with pending setup banner - Added a banner to indicate pending setup for Xibo OAuth credentials, with editable fields for client ID and secret. fix: Update appsettings.json to include Bitwarden configuration structure - Added Bitwarden section to appsettings.json for storing configuration values. chore: Update Docker Compose template with health checks - Added health check configuration for web service in template.yml to ensure service availability. refactor: Drop AppSettings table from database - Removed AppSettings table and related migration files as part of database cleanup. feat: Create ServiceLogEntry DTO for log management - Added ServiceLogEntry class to represent individual log entries from Docker services.
This commit is contained in:
@@ -1,255 +1,230 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Bitwarden.Sdk;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using OTSSignsOrchestrator.Core.Configuration;
|
||||
|
||||
namespace OTSSignsOrchestrator.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Stores and retrieves secrets from Bitwarden Secrets Manager (machine account API).
|
||||
/// Stores and retrieves secrets from Bitwarden Secrets Manager via the official Bitwarden C# SDK.
|
||||
///
|
||||
/// Configuration required in Settings:
|
||||
/// Bitwarden.IdentityUrl – defaults to https://identity.bitwarden.com
|
||||
/// Bitwarden.ApiUrl – defaults to https://api.bitwarden.com
|
||||
/// Bitwarden.AccessToken – machine account access token (sensitive)
|
||||
/// Bitwarden.OrganizationId – Bitwarden organisation that owns the project
|
||||
/// Bitwarden.ProjectId – project where new secrets are created
|
||||
/// Configuration is read from <see cref="BitwardenOptions"/> (bound to appsettings.json → "Bitwarden").
|
||||
///
|
||||
/// The SDK state file is persisted to %APPDATA%/OTSSignsOrchestrator/bitwarden.state
|
||||
/// so the SDK can cache its internal state across restarts.
|
||||
/// </summary>
|
||||
public class BitwardenSecretService : IBitwardenSecretService
|
||||
public class BitwardenSecretService : IBitwardenSecretService, IDisposable
|
||||
{
|
||||
private readonly IHttpClientFactory _http;
|
||||
private readonly SettingsService _settings;
|
||||
private readonly IOptionsMonitor<BitwardenOptions> _optionsMonitor;
|
||||
private readonly ILogger<BitwardenSecretService> _logger;
|
||||
|
||||
// Cached bearer token (refreshed per service lifetime — transient registration is assumed)
|
||||
private string? _bearerToken;
|
||||
// Lazily created on first use (per service instance — registered as Transient).
|
||||
private BitwardenClient? _client;
|
||||
private string? _clientAccessToken; // track which token the client was created with
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>Always returns the latest config snapshot (reloaded when appsettings.json changes).</summary>
|
||||
private BitwardenOptions Options => _optionsMonitor.CurrentValue;
|
||||
|
||||
public BitwardenSecretService(
|
||||
IHttpClientFactory http,
|
||||
SettingsService settings,
|
||||
IOptionsMonitor<BitwardenOptions> optionsMonitor,
|
||||
ILogger<BitwardenSecretService> logger)
|
||||
{
|
||||
_http = http;
|
||||
_settings = settings;
|
||||
_logger = logger;
|
||||
_optionsMonitor = optionsMonitor;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// IBitwardenSecretService
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public async Task<bool> IsConfiguredAsync()
|
||||
public Task<bool> IsConfiguredAsync()
|
||||
{
|
||||
var token = await _settings.GetAsync(SettingsService.BitwardenAccessToken);
|
||||
var orgId = await _settings.GetAsync(SettingsService.BitwardenOrganizationId);
|
||||
return !string.IsNullOrWhiteSpace(token) && !string.IsNullOrWhiteSpace(orgId);
|
||||
var opts = Options;
|
||||
var configured = !string.IsNullOrWhiteSpace(opts.AccessToken)
|
||||
&& !string.IsNullOrWhiteSpace(opts.OrganizationId);
|
||||
return Task.FromResult(configured);
|
||||
}
|
||||
|
||||
public async Task<string> CreateSecretAsync(string key, string value, string note = "")
|
||||
{
|
||||
var bearer = await GetBearerTokenAsync();
|
||||
var projectId = await _settings.GetAsync(SettingsService.BitwardenProjectId);
|
||||
var orgId = await _settings.GetAsync(SettingsService.BitwardenOrganizationId)
|
||||
?? throw new InvalidOperationException("Bitwarden OrganizationId is not configured.");
|
||||
var apiUrl = await GetApiUrlAsync();
|
||||
var client = await GetClientAsync();
|
||||
var orgId = GetOrgId();
|
||||
var projectIds = GetProjectIds();
|
||||
|
||||
var client = CreateClient(bearer);
|
||||
var body = new
|
||||
{
|
||||
organizationId = orgId,
|
||||
projectIds = projectId is not null ? new[] { projectId } : Array.Empty<string>(),
|
||||
key = key,
|
||||
value = value,
|
||||
note = note
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync($"{apiUrl}/secrets", body);
|
||||
await EnsureSuccessAsync(response, "create secret");
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<BwsSecretResponse>()
|
||||
?? throw new InvalidOperationException("Empty response from Bitwarden API.");
|
||||
var result = await Task.Run(() => client.Secrets.Create(orgId, key, value, note, projectIds));
|
||||
|
||||
_logger.LogInformation("Bitwarden secret created: key={Key}, id={Id}", key, result.Id);
|
||||
return result.Id;
|
||||
return result.Id.ToString();
|
||||
}
|
||||
|
||||
public async Task<string> CreateInstanceSecretAsync(string key, string value, string note = "")
|
||||
{
|
||||
var client = await GetClientAsync();
|
||||
var orgId = GetOrgId();
|
||||
var projectIds = GetInstanceProjectIds();
|
||||
|
||||
var result = await Task.Run(() => client.Secrets.Create(orgId, key, value, note, projectIds));
|
||||
|
||||
_logger.LogInformation("Bitwarden instance secret created: key={Key}, id={Id}, project={Project}",
|
||||
key, result.Id, Options.InstanceProjectId);
|
||||
return result.Id.ToString();
|
||||
}
|
||||
|
||||
public async Task<BitwardenSecret> GetSecretAsync(string secretId)
|
||||
{
|
||||
var bearer = await GetBearerTokenAsync();
|
||||
var apiUrl = await GetApiUrlAsync();
|
||||
var client = CreateClient(bearer);
|
||||
|
||||
var response = await client.GetAsync($"{apiUrl}/secrets/{secretId}");
|
||||
await EnsureSuccessAsync(response, "get secret");
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<BwsSecretResponse>()
|
||||
?? throw new InvalidOperationException("Empty response from Bitwarden API.");
|
||||
var client = await GetClientAsync();
|
||||
var result = await Task.Run(() => client.Secrets.Get(Guid.Parse(secretId)));
|
||||
|
||||
return new BitwardenSecret
|
||||
{
|
||||
Id = result.Id,
|
||||
Id = result.Id.ToString(),
|
||||
Key = result.Key,
|
||||
Value = result.Value,
|
||||
Note = result.Note ?? string.Empty,
|
||||
CreationDate = result.CreationDate
|
||||
CreationDate = result.CreationDate.DateTime
|
||||
};
|
||||
}
|
||||
|
||||
public async Task UpdateSecretAsync(string secretId, string key, string value, string note = "")
|
||||
{
|
||||
var bearer = await GetBearerTokenAsync();
|
||||
var projectId = await _settings.GetAsync(SettingsService.BitwardenProjectId);
|
||||
var orgId = await _settings.GetAsync(SettingsService.BitwardenOrganizationId)
|
||||
?? throw new InvalidOperationException("Bitwarden OrganizationId is not configured.");
|
||||
var apiUrl = await GetApiUrlAsync();
|
||||
var client = await GetClientAsync();
|
||||
var orgId = GetOrgId();
|
||||
var projectIds = GetProjectIds();
|
||||
|
||||
var client = CreateClient(bearer);
|
||||
var body = new
|
||||
{
|
||||
organizationId = orgId,
|
||||
projectIds = projectId is not null ? new[] { projectId } : Array.Empty<string>(),
|
||||
key = key,
|
||||
value = value,
|
||||
note = note
|
||||
};
|
||||
|
||||
var response = await client.PutAsJsonAsync($"{apiUrl}/secrets/{secretId}", body);
|
||||
await EnsureSuccessAsync(response, "update secret");
|
||||
await Task.Run(() => client.Secrets.Update(orgId, Guid.Parse(secretId), key, value, note, projectIds));
|
||||
|
||||
_logger.LogInformation("Bitwarden secret updated: key={Key}, id={Id}", key, secretId);
|
||||
}
|
||||
|
||||
public async Task UpdateInstanceSecretAsync(string secretId, string key, string value, string note = "")
|
||||
{
|
||||
var client = await GetClientAsync();
|
||||
var orgId = GetOrgId();
|
||||
var projectIds = GetInstanceProjectIds();
|
||||
|
||||
await Task.Run(() => client.Secrets.Update(orgId, Guid.Parse(secretId), key, value, note, projectIds));
|
||||
|
||||
_logger.LogInformation("Bitwarden instance secret updated: key={Key}, id={Id}, project={Project}",
|
||||
key, secretId, Options.InstanceProjectId);
|
||||
}
|
||||
|
||||
public async Task<List<BitwardenSecretSummary>> ListSecretsAsync()
|
||||
{
|
||||
var bearer = await GetBearerTokenAsync();
|
||||
var orgId = await _settings.GetAsync(SettingsService.BitwardenOrganizationId)
|
||||
?? throw new InvalidOperationException("Bitwarden OrganizationId is not configured.");
|
||||
var apiUrl = await GetApiUrlAsync();
|
||||
var client = CreateClient(bearer);
|
||||
var client = await GetClientAsync();
|
||||
var orgId = GetOrgId();
|
||||
var result = await Task.Run(() => client.Secrets.List(orgId));
|
||||
|
||||
var response = await client.GetAsync($"{apiUrl}/organizations/{orgId}/secrets");
|
||||
await EnsureSuccessAsync(response, "list secrets");
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<BwsSecretsListResponse>();
|
||||
|
||||
return result?.Data?.Select(s => new BitwardenSecretSummary
|
||||
return result.Data?.Select(s => new BitwardenSecretSummary
|
||||
{
|
||||
Id = s.Id,
|
||||
Id = s.Id.ToString(),
|
||||
Key = s.Key,
|
||||
CreationDate = s.CreationDate
|
||||
CreationDate = DateTime.MinValue
|
||||
}).ToList() ?? new List<BitwardenSecretSummary>();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Auth
|
||||
// SDK client initialisation
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Exchanges the machine-account access token for a short-lived Bearer token.
|
||||
/// The access token format is: 0.{tokenId}.{clientSecret}:{encKeyB64}
|
||||
/// The client_id used is "machine.{tokenId}".
|
||||
/// Returns an authenticated <see cref="BitwardenClient"/>, creating and logging in on first use.
|
||||
/// </summary>
|
||||
private async Task<string> GetBearerTokenAsync()
|
||||
private async Task<BitwardenClient> GetClientAsync()
|
||||
{
|
||||
if (_bearerToken is not null)
|
||||
return _bearerToken;
|
||||
var opts = Options;
|
||||
|
||||
var rawToken = await _settings.GetAsync(SettingsService.BitwardenAccessToken)
|
||||
?? throw new InvalidOperationException("Bitwarden AccessToken is not configured in Settings.");
|
||||
var identityUrl = await GetIdentityUrlAsync();
|
||||
|
||||
// Parse token: "0.<tokenId>.<clientSecretAndKey>" — split off the first two segments
|
||||
var parts = rawToken.Split('.', 3);
|
||||
if (parts.Length < 3)
|
||||
throw new FormatException(
|
||||
"Bitwarden access token has unexpected format. Expected: 0.<tokenId>.<rest>");
|
||||
|
||||
var tokenId = parts[1];
|
||||
var clientSecret = parts[2]; // may contain ":base64key" suffix — include all of it
|
||||
|
||||
var client = _http.CreateClient("Bitwarden");
|
||||
var form = new FormUrlEncodedContent(new[]
|
||||
// If credentials changed since the client was created, tear it down so we re-auth
|
||||
if (_client is not null && _clientAccessToken != opts.AccessToken)
|
||||
{
|
||||
new KeyValuePair<string, string>("grant_type", "client_credentials"),
|
||||
new KeyValuePair<string, string>("client_id", $"machine.{tokenId}"),
|
||||
new KeyValuePair<string, string>("client_secret", clientSecret),
|
||||
new KeyValuePair<string, string>("scope", "api.secrets"),
|
||||
});
|
||||
_logger.LogInformation("Bitwarden credentials changed — recreating SDK client");
|
||||
_client.Dispose();
|
||||
_client = null;
|
||||
}
|
||||
|
||||
var response = await client.PostAsync($"{identityUrl}/connect/token", form);
|
||||
await EnsureSuccessAsync(response, "authenticate with Bitwarden identity");
|
||||
if (_client is not null)
|
||||
return _client;
|
||||
|
||||
var token = await response.Content.ReadFromJsonAsync<BwsTokenResponse>()
|
||||
?? throw new InvalidOperationException("Empty token response from Bitwarden.");
|
||||
if (string.IsNullOrWhiteSpace(opts.AccessToken))
|
||||
throw new InvalidOperationException("Bitwarden AccessToken is not configured. Set it in Settings → Bitwarden.");
|
||||
|
||||
_bearerToken = token.AccessToken;
|
||||
_logger.LogInformation("Bitwarden bearer token acquired.");
|
||||
return _bearerToken;
|
||||
var accessToken = opts.AccessToken;
|
||||
var apiUrl = (opts.ApiUrl ?? "https://api.bitwarden.com").TrimEnd('/');
|
||||
var identityUrl = (opts.IdentityUrl ?? "https://identity.bitwarden.com").TrimEnd('/');
|
||||
|
||||
var sdkSettings = new BitwardenSettings { ApiUrl = apiUrl, IdentityUrl = identityUrl };
|
||||
var client = new BitwardenClient(sdkSettings);
|
||||
|
||||
await Task.Run(() => client.Auth.LoginAccessToken(accessToken, GetStateFilePath()));
|
||||
|
||||
_logger.LogInformation("Bitwarden SDK client initialised and authenticated.");
|
||||
_client = client;
|
||||
_clientAccessToken = accessToken;
|
||||
return _client;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private async Task<string> GetIdentityUrlAsync()
|
||||
=> (await _settings.GetAsync(SettingsService.BitwardenIdentityUrl, "https://identity.bitwarden.com")).TrimEnd('/');
|
||||
|
||||
private async Task<string> GetApiUrlAsync()
|
||||
=> (await _settings.GetAsync(SettingsService.BitwardenApiUrl, "https://api.bitwarden.com")).TrimEnd('/');
|
||||
|
||||
private static HttpClient CreateClient(string bearerToken)
|
||||
private Guid GetOrgId()
|
||||
{
|
||||
var client = new HttpClient();
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", bearerToken);
|
||||
return client;
|
||||
var orgId = Options.OrganizationId;
|
||||
if (string.IsNullOrWhiteSpace(orgId))
|
||||
throw new InvalidOperationException("Bitwarden OrganizationId is not configured.");
|
||||
return Guid.Parse(orgId);
|
||||
}
|
||||
|
||||
private static async Task EnsureSuccessAsync(HttpResponseMessage response, string operation)
|
||||
private Guid[] GetProjectIds()
|
||||
{
|
||||
if (!response.IsSuccessStatusCode)
|
||||
var projectId = Options.ProjectId;
|
||||
if (string.IsNullOrWhiteSpace(projectId))
|
||||
throw new InvalidOperationException(
|
||||
"Bitwarden ProjectId is required. Set it in Settings → Bitwarden.");
|
||||
return new[] { Guid.Parse(projectId) };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the project IDs array for instance-level secrets.
|
||||
/// Uses <see cref="BitwardenOptions.InstanceProjectId"/> when configured,
|
||||
/// otherwise falls back to the default <see cref="BitwardenOptions.ProjectId"/>.
|
||||
/// </summary>
|
||||
private Guid[] GetInstanceProjectIds()
|
||||
{
|
||||
var instanceProjectId = Options.InstanceProjectId;
|
||||
if (!string.IsNullOrWhiteSpace(instanceProjectId))
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
throw new HttpRequestException(
|
||||
$"Bitwarden API call '{operation}' failed: {(int)response.StatusCode} {response.ReasonPhrase} — {body}");
|
||||
_logger.LogDebug("Using instance Bitwarden project: {ProjectId}", instanceProjectId);
|
||||
return new[] { Guid.Parse(instanceProjectId) };
|
||||
}
|
||||
|
||||
// Fall back to the default config project
|
||||
return GetProjectIds();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the path where the SDK stores its state between sessions.
|
||||
/// Stored under %APPDATA%/OTSSignsOrchestrator/bitwarden.state.
|
||||
/// </summary>
|
||||
private static string GetStateFilePath()
|
||||
{
|
||||
var dir = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"OTSSignsOrchestrator");
|
||||
Directory.CreateDirectory(dir);
|
||||
return Path.Combine(dir, "bitwarden.state");
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// IDisposable
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (!_disposed)
|
||||
{
|
||||
_client?.Dispose();
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Internal DTOs
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private sealed class BwsTokenResponse
|
||||
{
|
||||
[JsonPropertyName("access_token")]
|
||||
public string AccessToken { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
private sealed class BwsSecretResponse
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("organizationId")]
|
||||
public string OrganizationId { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("key")]
|
||||
public string Key { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("value")]
|
||||
public string Value { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("note")]
|
||||
public string? Note { get; set; }
|
||||
|
||||
[JsonPropertyName("creationDate")]
|
||||
public DateTime CreationDate { get; set; }
|
||||
}
|
||||
|
||||
private sealed class BwsSecretsListResponse
|
||||
{
|
||||
[JsonPropertyName("data")]
|
||||
public List<BwsSecretResponse>? Data { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user