server-monitor-manager/src/ServerMonitorManager.Control/ControlMaintenance.cs
ochenstarik-ui 3851d87534
Add standalone Linux bootstrap foundation (#5)
* Add standalone Linux bootstrap foundation

* Record bootstrap foundation progress

* Add single-code Node enrollment

* Validate bootstrap release payloads

* Add managed WireGuard mesh bootstrap

* Exercise repeated systemd installation in CI

* Expose systemd smoke failure diagnostics

* Fix bootstrap os-release variable collision

* Create policy helper directory during install

* Capture systemd smoke healthcheck errors

* Report Control listener diagnostics in CI

* Capture stalled Control process diagnostics

* Set service working directories

* Run protected CA healthcheck as root

* Add local Mesh emergency recovery

* Add multi-architecture Linux system matrix

* Add provisioning job control plane

* Stabilize Debian systemd smoke files

* Add node-scoped provisioning job channel

* Add provisioning progress state machine

* Reconcile expired provisioning jobs

* Add provisioning rollback workflow

* Add redacted provisioning event history

* Add restricted provisioning preflight helper

* Persist typed provisioning preflight facts

* Add preflight desired state drift detection

* Define strict base install schema catalog

* Add safe base install plan generation

* Add pre-confirmation base install plans

* Test base install plan API flow

* Authorize confirmed provisioning execution

---------

Co-authored-by: Ochenstarik <ochenstarik@inbox.ru>
2026-07-30 21:18:39 +07:00

309 lines
13 KiB
C#

using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Options;
namespace ServerMonitorManager.Control;
public sealed record ControlMaintenanceResult(
int MetricsDeleted,
int IdempotencyDeleted,
int AuditDeleted,
int TokensDeleted,
int ProvisioningJobsCancelled,
int ProvisioningJobsNeedingReconciliation);
public sealed class LinkExpirationBackgroundService(
LinkService links,
IOptions<ControlOptions> options,
TimeProvider timeProvider,
ILogger<LinkExpirationBackgroundService> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(
TimeSpan.FromSeconds(options.Value.LinkExpirationPollSeconds), timeProvider);
do
{
try
{
var result = await links.ExpireDueLinksAsync(timeProvider.GetUtcNow(), stoppingToken);
if (result.Disabled > 0 || result.Failed > 0)
{
logger.LogInformation(
"TTL reconciliation completed: {Disabled} disabled, {Failed} failed.",
result.Disabled,
result.Failed);
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception exception)
{
logger.LogError(exception, "TTL reconciliation failed.");
}
}
while (await timer.WaitForNextTickAsync(stoppingToken));
}
}
public sealed class ControlMaintenanceBackgroundService(
ControlStore store,
ControlBackupService backups,
IOptions<ControlOptions> options,
TimeProvider timeProvider,
ILogger<ControlMaintenanceBackgroundService> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(
TimeSpan.FromMinutes(options.Value.MaintenanceIntervalMinutes), timeProvider);
do
{
try
{
var result = await store.MaintainAsync(timeProvider.GetUtcNow(), stoppingToken);
await backups.CreateIfDueAsync(timeProvider.GetUtcNow(), stoppingToken);
if (result.MetricsDeleted + result.IdempotencyDeleted + result.AuditDeleted
+ result.TokensDeleted + result.ProvisioningJobsCancelled
+ result.ProvisioningJobsNeedingReconciliation > 0)
{
logger.LogInformation(
"Control maintenance removed {Metrics} metrics, {Idempotency} replay records, "
+ "{Audit} audit records, and {Tokens} enrollment tokens; cancelled {Cancelled} "
+ "expired jobs and marked {Reconciliation} jobs for reconciliation.",
result.MetricsDeleted,
result.IdempotencyDeleted,
result.AuditDeleted,
result.TokensDeleted,
result.ProvisioningJobsCancelled,
result.ProvisioningJobsNeedingReconciliation);
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception exception)
{
logger.LogError(exception, "Control database maintenance failed.");
}
}
while (await timer.WaitForNextTickAsync(stoppingToken));
}
}
public sealed class ControlBackupService(
ControlStore store,
IOptions<ControlOptions> options,
ILogger<ControlBackupService> logger)
{
private const string ManifestFileName = "manifest.json";
private readonly ControlOptions _options = options.Value;
public async Task<string> CreateAsync(DateTimeOffset now, CancellationToken cancellationToken = default)
{
Directory.CreateDirectory(_options.BackupDirectory);
SetDirectoryPermissions(_options.BackupDirectory);
var name = $"backup-{now:yyyyMMddTHHmmssfffZ}-{Guid.NewGuid():N}";
var temporary = Path.Combine(_options.BackupDirectory, $".{name}.tmp");
var destination = Path.Combine(_options.BackupDirectory, name);
Directory.CreateDirectory(temporary);
try
{
var databaseName = "control.db";
var authorityName = "control-ca.pfx";
var databasePath = Path.Combine(temporary, databaseName);
var authorityPath = Path.Combine(temporary, authorityName);
await store.BackupDatabaseAsync(databasePath, cancellationToken);
File.Copy(_options.CertificateAuthorityPath, authorityPath, overwrite: false);
SetFilePermissions(databasePath);
SetFilePermissions(authorityPath);
var manifest = new ControlBackupManifest(
1,
now,
databaseName,
await ComputeSha256Async(databasePath, cancellationToken),
authorityName,
await ComputeSha256Async(authorityPath, cancellationToken));
await File.WriteAllTextAsync(
Path.Combine(temporary, ManifestFileName),
JsonSerializer.Serialize(manifest, ControlMaintenanceJsonContext.Default.ControlBackupManifest),
cancellationToken);
SetFilePermissions(Path.Combine(temporary, ManifestFileName));
Directory.Move(temporary, destination);
SetDirectoryPermissions(destination);
TrimOldBackups();
logger.LogInformation("Control backup created at {BackupPath}.", destination);
return destination;
}
catch
{
if (Directory.Exists(temporary))
{
Directory.Delete(temporary, recursive: true);
}
throw;
}
}
public async Task<string?> CreateIfDueAsync(
DateTimeOffset now,
CancellationToken cancellationToken = default)
{
Directory.CreateDirectory(_options.BackupDirectory);
var newest = Directory.EnumerateDirectories(_options.BackupDirectory, "backup-*")
.Select(Directory.GetLastWriteTimeUtc)
.OrderByDescending(value => value)
.FirstOrDefault();
return newest == default || now - newest >= TimeSpan.FromHours(_options.BackupIntervalHours)
? await CreateAsync(now, cancellationToken)
: null;
}
public async Task RestoreAsync(string backupPath, CancellationToken cancellationToken = default)
{
var root = Path.GetFullPath(backupPath);
var manifestPath = Path.Combine(root, ManifestFileName);
var manifest = JsonSerializer.Deserialize(
await File.ReadAllTextAsync(manifestPath, cancellationToken),
ControlMaintenanceJsonContext.Default.ControlBackupManifest)
?? throw new InvalidDataException("Backup manifest is empty.");
if (manifest.Version != 1
|| Path.GetFileName(manifest.DatabaseFile) != manifest.DatabaseFile
|| Path.GetFileName(manifest.CertificateAuthorityFile) != manifest.CertificateAuthorityFile)
{
throw new InvalidDataException("Unsupported or unsafe backup manifest.");
}
var databaseSource = Path.Combine(root, manifest.DatabaseFile);
var authoritySource = Path.Combine(root, manifest.CertificateAuthorityFile);
await VerifyHashAsync(databaseSource, manifest.DatabaseSha256, cancellationToken);
await VerifyHashAsync(authoritySource, manifest.CertificateAuthoritySha256, cancellationToken);
await VerifyDatabaseAsync(databaseSource, cancellationToken);
var safetyDirectory = Path.Combine(
_options.BackupDirectory,
$"pre-restore-{DateTimeOffset.UtcNow:yyyyMMddTHHmmssfffZ}-{Guid.NewGuid():N}");
Directory.CreateDirectory(safetyDirectory);
if (File.Exists(_options.DatabasePath))
{
var safetyDatabase = Path.Combine(safetyDirectory, "control.db");
File.Copy(_options.DatabasePath, safetyDatabase);
SetFilePermissions(safetyDatabase);
}
if (File.Exists(_options.CertificateAuthorityPath))
{
var safetyAuthority = Path.Combine(safetyDirectory, "control-ca.pfx");
File.Copy(_options.CertificateAuthorityPath, safetyAuthority);
SetFilePermissions(safetyAuthority);
}
SetDirectoryPermissions(safetyDirectory);
await ReplaceFileAsync(databaseSource, _options.DatabasePath, cancellationToken);
await ReplaceFileAsync(authoritySource, _options.CertificateAuthorityPath, cancellationToken);
File.Delete(_options.DatabasePath + "-wal");
File.Delete(_options.DatabasePath + "-shm");
SetFilePermissions(_options.DatabasePath);
SetFilePermissions(_options.CertificateAuthorityPath);
logger.LogWarning(
"Control state restored from {BackupPath}. Previous files are in {SafetyPath}.",
root,
safetyDirectory);
}
private void TrimOldBackups()
{
foreach (var directory in Directory.EnumerateDirectories(_options.BackupDirectory, "backup-*")
.OrderByDescending(Directory.GetLastWriteTimeUtc)
.Skip(_options.BackupRetentionCount))
{
Directory.Delete(directory, recursive: true);
}
}
private static async Task ReplaceFileAsync(
string source,
string destination,
CancellationToken cancellationToken)
{
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(destination))!);
var temporary = destination + $".restore-{Guid.NewGuid():N}.tmp";
await using (var input = File.OpenRead(source))
await using (var output = new FileStream(
temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None, 81920, useAsync: true))
{
await input.CopyToAsync(output, cancellationToken);
await output.FlushAsync(cancellationToken);
}
File.Move(temporary, destination, overwrite: true);
}
private static async Task VerifyDatabaseAsync(string path, CancellationToken cancellationToken)
{
await using var connection = new SqliteConnection(new SqliteConnectionStringBuilder
{
DataSource = path,
Mode = SqliteOpenMode.ReadOnly,
Pooling = false
}.ToString());
await connection.OpenAsync(cancellationToken);
var command = connection.CreateCommand();
command.CommandText = "PRAGMA integrity_check;";
var result = Convert.ToString(await command.ExecuteScalarAsync(cancellationToken));
if (!string.Equals(result, "ok", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidDataException($"Backup database integrity check failed: {result}");
}
}
private static async Task VerifyHashAsync(
string path,
string expected,
CancellationToken cancellationToken)
{
var actual = await ComputeSha256Async(path, cancellationToken);
if (!CryptographicOperations.FixedTimeEquals(
Convert.FromHexString(actual), Convert.FromHexString(expected)))
{
throw new InvalidDataException($"Backup checksum mismatch for {Path.GetFileName(path)}.");
}
}
private static async Task<string> ComputeSha256Async(string path, CancellationToken cancellationToken)
{
await using var stream = File.OpenRead(path);
var hash = await SHA256.HashDataAsync(stream, cancellationToken);
return Convert.ToHexStringLower(hash);
}
private static void SetDirectoryPermissions(string path)
{
if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS())
{
File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
}
}
private static void SetFilePermissions(string path)
{
if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS())
{
File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite);
}
}
}
internal sealed record ControlBackupManifest(
int Version,
DateTimeOffset CreatedAt,
string DatabaseFile,
string DatabaseSha256,
string CertificateAuthorityFile,
string CertificateAuthoritySha256);
[JsonSerializable(typeof(ControlBackupManifest))]
internal sealed partial class ControlMaintenanceJsonContext : JsonSerializerContext;