server-monitor-manager/src/ServerMonitorManager.Control/CertificateLifecycleService.cs

162 lines
5.9 KiB
C#

using System.Text.Json;
using ServerMonitorManager.Core;
namespace ServerMonitorManager.Control;
public sealed class CertificateLifecycleService(
ControlStore store,
LinkService links,
ControlEventBroker events,
CertificateAuthority? ca = null)
{
private static readonly TimeSpan TicketLifetime = TimeSpan.FromMinutes(10);
public async Task<IssuedCertificate> RenewAgentCertificateAsync(
string nodeId,
CertificateRenewalRequest request,
string authenticatedNodeId,
CancellationToken cancellationToken = default)
{
if (!string.Equals(nodeId, authenticatedNodeId, StringComparison.Ordinal)
|| !string.Equals(request.EntityId, authenticatedNodeId, StringComparison.Ordinal))
{
throw new InvalidOperationException("Node ID mismatch for certificate renewal.");
}
var isRevoked = await store.IsAgentRevokedAsync(nodeId, cancellationToken);
if (isRevoked)
{
throw new InvalidOperationException("Revoked agent cannot renew certificate.");
}
if (ca is null)
{
throw new InvalidOperationException("Certificate authority is not configured.");
}
var issued = ca.IssueClientCertificate(nodeId, request.CertificateSigningRequestPem);
await store.UpdateAgentCertificateAsync(nodeId, issued.Thumbprint, issued.ExpiresAt, cancellationToken);
events.Publish(
"certificate.renewed",
nodeId,
JsonSerializer.Serialize(
new CertificateStatusEvent("Agent", nodeId, "Renewed", 0),
SmmJsonContext.Default.CertificateStatusEvent));
return issued;
}
public async Task<IssuedCertificate> RenewDeviceCertificateAsync(
string deviceId,
CertificateRenewalRequest request,
string authenticatedDeviceId,
CancellationToken cancellationToken = default)
{
if (!string.Equals(deviceId, authenticatedDeviceId, StringComparison.Ordinal)
|| !string.Equals(request.EntityId, authenticatedDeviceId, StringComparison.Ordinal))
{
throw new InvalidOperationException("Device ID mismatch for certificate renewal.");
}
var isRevoked = await store.IsDeviceRevokedAsync(deviceId, cancellationToken);
if (isRevoked)
{
throw new InvalidOperationException("Revoked device cannot renew certificate.");
}
if (ca is null)
{
throw new InvalidOperationException("Certificate authority is not configured.");
}
var issued = ca.IssueClientCertificate(deviceId, request.CertificateSigningRequestPem);
await store.UpdateDeviceCertificateAsync(deviceId, issued.Thumbprint, issued.ExpiresAt, cancellationToken);
events.Publish(
"certificate.renewed",
deviceId,
JsonSerializer.Serialize(
new CertificateStatusEvent("Operator", deviceId, "Renewed", 0),
SmmJsonContext.Default.CertificateStatusEvent));
return issued;
}
public async Task CheckAndPublishExpiringCertificatesAsync(CancellationToken cancellationToken = default)
{
var agents = await store.ListAgentsAsync(cancellationToken);
foreach (var agent in agents)
{
if (agent.CertificateExpiresAt.HasValue)
{
var remaining = agent.CertificateExpiresAt.Value - DateTimeOffset.UtcNow;
if (remaining > TimeSpan.Zero && remaining.TotalDays < 10)
{
events.Publish(
"certificate.expiring",
agent.NodeId,
JsonSerializer.Serialize(
new CertificateStatusEvent("Agent", agent.NodeId, "Expiring", 0),
SmmJsonContext.Default.CertificateStatusEvent));
}
}
}
}
public async Task<CertificateReenrollmentTicket?> ReenrollAgentAsync(
string nodeId,
CertificateReenrollmentRequest request,
string actor,
CancellationToken cancellationToken)
{
using var nodeLease = await links.AcquireNodeLocksAsync([nodeId], cancellationToken);
var mutation = await store.BeginAgentReenrollmentAsync(
nodeId, request, actor, TicketLifetime, cancellationToken);
if (mutation is null)
{
return null;
}
if (!mutation.IsReplay)
{
PublishCertificate("agent.revoked", mutation.Ticket);
}
var pendingLinks = mutation.IsReplay
? (await store.ListEffectiveLinksForNodeAsync(nodeId, cancellationToken))
.Where(link => link.DesiredState == "Disabled")
: mutation.Links;
foreach (var pendingLink in pendingLinks)
{
await links.ConvergeDisabledAsync(pendingLink, actor, cancellationToken);
}
return mutation.Ticket;
}
public async Task<CertificateReenrollmentTicket?> ReenrollDeviceAsync(
string deviceId,
CertificateReenrollmentRequest request,
string actor,
CancellationToken cancellationToken)
{
var ticket = await store.BeginDeviceReenrollmentAsync(
deviceId, request, actor, TicketLifetime, cancellationToken);
if (ticket is not null)
{
PublishCertificate("device.revoked", ticket);
}
return ticket;
}
private void PublishCertificate(string type, CertificateReenrollmentTicket ticket)
=> events.Publish(
type,
ticket.EntityId,
JsonSerializer.Serialize(
new CertificateStatusEvent(
ticket.EntityType,
ticket.EntityId,
"Revoked",
ticket.DisabledLinks),
SmmJsonContext.Default.CertificateStatusEvent));
}