From 1e5e44a6addbcd3a2c3f8df216184ba47931d5e5 Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Thu, 16 Jul 2026 21:59:20 +0700 Subject: [PATCH] Move Links to SQLite control plane --- README.md | 5 + deploy/ochenstarik-smm-control.service | 1 - docs/architecture.md | 4 +- docs/installer-contract.md | 4 + docs/roadmap.md | 6 +- docs/security-model.md | 6 + .../ControlEventBroker.cs | 46 ++ .../ControlOptions.cs | 2 + .../ControlStore.cs | 435 +++++++++++++++++- .../LinkPolicyApplier.cs | 69 +++ .../LinkService.cs | 82 ++++ src/ServerMonitorManager.Control/Program.cs | 173 ++++++- .../appsettings.json | 3 +- src/ServerMonitorManager.Core/Contracts.cs | 48 ++ .../SmmJsonContext.cs | 8 + .../ControlClientService.cs | 318 +++++++++++++ .../MainPage.xaml | 5 + .../MainPage.xaml.cs | 237 +++++++++- .../MeshModels.cs | 5 +- .../ServerMonitorManager.Desktop.csproj | 1 + .../ControlStoreTests.cs | 146 ++++++ 21 files changed, 1584 insertions(+), 20 deletions(-) create mode 100644 src/ServerMonitorManager.Control/ControlEventBroker.cs create mode 100644 src/ServerMonitorManager.Control/LinkPolicyApplier.cs create mode 100644 src/ServerMonitorManager.Control/LinkService.cs create mode 100644 src/ServerMonitorManager.Desktop/ControlClientService.cs diff --git a/README.md b/README.md index 64f8236..dd2b0bc 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ Alpha-установка постоянного слоя после обычны # Hub sudo ./ochenstarik-server-monitor-manager.sh install-control-hub sudo ./ochenstarik-server-monitor-manager.sh control-code home +sudo ./ochenstarik-server-monitor-manager.sh control-device-code windows-pc # соответствующий Node — вставить полученный SMMCTL1 sudo ./ochenstarik-server-monitor-manager.sh install-control-agent @@ -60,6 +61,10 @@ sudo ./ochenstarik-server-monitor-manager.sh install-control-agent Архив выбирается автоматически для amd64 или arm64 и проверяется по SHA-256. Для Control Hub требуется входящий TCP-порт `7443`; Agent открытых входящих портов не создаёт. +Код `SMMDEV1` предназначен только для Windows-клиента: приложение локально создаёт operator key и получает отдельный mTLS-сертификат. Agent identities не могут читать весь inventory, изменять Links или подключаться к потоку операторских событий. + +После регистрации кнопка `Control Hub` сохраняет operator certificate через DPAPI. Панель Mesh автоматически переключается с переходного SSH-протокола на SQLite Control API, а изменения Links и heartbeat поступают через защищённый поток событий без ручного опроса интерфейса. + Проверка control layer для разработчиков: ```bash diff --git a/deploy/ochenstarik-smm-control.service b/deploy/ochenstarik-smm-control.service index 7b3f95d..d4dac12 100644 --- a/deploy/ochenstarik-smm-control.service +++ b/deploy/ochenstarik-smm-control.service @@ -11,7 +11,6 @@ EnvironmentFile=/etc/ochenstarik-server-monitor-manager/control.env ExecStart=/usr/local/lib/ochenstarik-server-monitor-manager/control/ochenstarik-smm-control Restart=on-failure RestartSec=10s -NoNewPrivileges=true PrivateTmp=true PrivateDevices=true ProtectSystem=strict diff --git a/docs/architecture.md b/docs/architecture.md index a7bbfd1..b37584b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -98,7 +98,9 @@ Hub остаётся маршрутизатором Mesh первого поко Первый реализованный срез control layer использует ASP.NET Core 10 и SQLite. Hub выдаёт агенту сертификат по CSR только после атомарного погашения десятиминутного token. После регистрации Agent выполняет только исходящие HTTPS-запросы с mTLS, а Hub связывает thumbprint сертификата с конкретным `node_id`. Heartbeat содержит idempotency key и отклоняется при попытке повторить тот же ключ с другим телом запроса. -Сейчас этот срез сохраняет inventory, heartbeat-метрики, idempotency и аудит регистрации. Перенос политик Links из файлов в SQLite, постоянный event stream и локальный буфер Agent выполняются следующими частями этапа. +Control Hub сохраняет inventory, heartbeat-метрики, направленные Links, idempotency и аудит в SQLite. Желаемое состояние отключения Link фиксируется до вызова ограниченного nftables wrapper; при ошибке фактическое состояние становится `Partial`. Отдельная mTLS identity `Operator` читает inventory, управляет Links и получает NDJSON event stream, а сертификат `Agent` ограничен heartbeat собственного `node_id`. + +Локальный буфер Agent, downsampling и подключение event stream к WinUI выполняются следующими частями этапа. ## 7. Целевые ограничения MVP diff --git a/docs/installer-contract.md b/docs/installer-contract.md index f8aa33f..33165cd 100644 --- a/docs/installer-contract.md +++ b/docs/installer-contract.md @@ -44,10 +44,13 @@ - `install-control-hub` скачивает release-архив под amd64/arm64, проверяет SHA-256, создаёт локальный CA, HTTPS-сертификат Hub, SQLite-каталог и изолированный systemd service; - `control-code NAME` создаёт десятиминутный token и код `SMMCTL1`, содержащий URL Hub и только публичный CA; +- `control-device-code DEVICE` создаёт отдельный код `SMMDEV1` для operator identity Windows-клиента; - `install-control-agent` проверяет CA, локально создаёт ключ и CSR, регистрирует сертификат и запускает исходящий mTLS Agent через systemd. Приватный ключ Control CA не включается в `SMMCTL1`, а приватный ключ Agent не покидает Node. По умолчанию Control Hub слушает TCP `7443`. +Control service не получает общий доступ к root helper. Отдельный root-owned wrapper принимает только проверенные `link-connect` и `link-disconnect`; команды регистрации, удаления Node и произвольные аргументы ему недоступны. + ## Команды жизненного цикла Целевой интерфейс: @@ -59,6 +62,7 @@ install-node install-control-hub install-control-agent control-code NAME +control-device-code DEVICE status update rollback diff --git a/docs/roadmap.md b/docs/roadmap.md index 7a05f73..315c574 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -41,7 +41,7 @@ - [x] срок действия не более 10 минут; - [x] атомарное погашение token; - [ ] отзыв и повторная регистрация Node; -- [ ] подтверждение fingerprint Hub; +- [x] подтверждение SHA-256 fingerprint Control CA Hub; - [x] защита desktop SSH-ключа через DPAPI. ## Этап 4 — управляемые Links @@ -71,9 +71,9 @@ ## Этап 6 — постоянный control layer - [x] самодостаточный single-file Linux agent для amd64/arm64; -- [ ] SQLite inventory, policies, history и audit; +- [x] SQLite inventory, policies, history и audit; - [x] исходящие mTLS agent sessions; -- [ ] WebSocket/stream событий для desktop client; +- [x] защищённый Hub event stream для desktop client; - [ ] ограниченный локальный буфер и downsampling; - [x] idempotency key и защита от replay; - [ ] тест нагрузки 50–100 Node на одном Hub. diff --git a/docs/security-model.md b/docs/security-model.md index a013bd5..d050594 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -29,6 +29,12 @@ Windows-клиент использует отдельный Ed25519-ключ т До появления mTLS enrollment выполняется через отдельную ограниченную SSH-команду. Token не должен содержать приватный WireGuard-ключ. +## Operator identity Windows-клиента + +Windows-клиент получает отдельный код `SMMDEV1`, показывает URL и SHA-256 fingerprint Control CA и продолжает регистрацию только после явного подтверждения пользователя. Приватный operator key создаётся локально, хранится в DPAPI current-user scope и не используется как SSH monitoring, terminal или Agent identity. + +Сертификат `Agent` разрешает только heartbeat собственного `node_id`. Только сертификат с ролью `Operator` может читать общий inventory, изменять Links и подписываться на event stream. Control service вызывает от root только отдельный wrapper с командами `link-connect` и `link-disconnect`; другие Hub-команды через него запрещены. + ## Авторизация Links - мониторинг read-only отделён от управления; diff --git a/src/ServerMonitorManager.Control/ControlEventBroker.cs b/src/ServerMonitorManager.Control/ControlEventBroker.cs new file mode 100644 index 0000000..aa4a385 --- /dev/null +++ b/src/ServerMonitorManager.Control/ControlEventBroker.cs @@ -0,0 +1,46 @@ +using System.Collections.Concurrent; +using System.Threading.Channels; +using ServerMonitorManager.Core; + +namespace ServerMonitorManager.Control; + +public sealed class ControlEventBroker +{ + private readonly ConcurrentDictionary> _subscribers = new(); + private long _sequence; + + public ControlEvent Publish(string type, string subject, string payloadJson) + { + var controlEvent = new ControlEvent( + Interlocked.Increment(ref _sequence), + type, + subject, + DateTimeOffset.UtcNow, + payloadJson); + foreach (var channel in _subscribers.Values) + { + channel.Writer.TryWrite(controlEvent); + } + return controlEvent; + } + + public ControlEventSubscription Subscribe() + { + var id = Guid.NewGuid(); + var channel = Channel.CreateBounded(new BoundedChannelOptions(256) + { + FullMode = BoundedChannelFullMode.DropOldest, + SingleReader = true, + SingleWriter = false + }); + _subscribers[id] = channel; + return new ControlEventSubscription(channel.Reader, () => _subscribers.TryRemove(id, out _)); + } +} + +public sealed class ControlEventSubscription(ChannelReader reader, Action unsubscribe) : IDisposable +{ + public ChannelReader Reader { get; } = reader; + + public void Dispose() => unsubscribe(); +} diff --git a/src/ServerMonitorManager.Control/ControlOptions.cs b/src/ServerMonitorManager.Control/ControlOptions.cs index a9bb1c1..58c981f 100644 --- a/src/ServerMonitorManager.Control/ControlOptions.cs +++ b/src/ServerMonitorManager.Control/ControlOptions.cs @@ -11,4 +11,6 @@ public sealed class ControlOptions public string? CertificateAuthorityPassword { get; init; } public int HeartbeatSeconds { get; init; } = 30; + + public string HubHelperPath { get; init; } = "/usr/local/libexec/ochenstarik-smm-policy-apply"; } diff --git a/src/ServerMonitorManager.Control/ControlStore.cs b/src/ServerMonitorManager.Control/ControlStore.cs index c925441..2c82eaa 100644 --- a/src/ServerMonitorManager.Control/ControlStore.cs +++ b/src/ServerMonitorManager.Control/ControlStore.cs @@ -41,6 +41,19 @@ public sealed class ControlStore(IOptions options) agent_version TEXT NOT NULL DEFAULT '', last_seen_at TEXT NULL ); + CREATE TABLE IF NOT EXISTS device_tokens ( + token_hash TEXT PRIMARY KEY, + device_id TEXT NOT NULL, + expires_at TEXT NOT NULL, + consumed_at TEXT NULL + ); + CREATE TABLE IF NOT EXISTS devices ( + device_id TEXT PRIMARY KEY, + certificate_thumbprint TEXT NOT NULL UNIQUE, + certificate_expires_at TEXT NOT NULL, + status TEXT NOT NULL, + last_seen_at TEXT NULL + ); CREATE TABLE IF NOT EXISTS metric_samples ( sequence INTEGER PRIMARY KEY AUTOINCREMENT, node_id TEXT NOT NULL REFERENCES agents(node_id) ON DELETE CASCADE, @@ -63,6 +76,25 @@ public sealed class ControlStore(IOptions options) subject TEXT NOT NULL, details_json TEXT NOT NULL ); + CREATE TABLE IF NOT EXISTS links ( + id TEXT PRIMARY KEY, + source_node_id TEXT NOT NULL REFERENCES agents(node_id), + target_node_id TEXT NOT NULL REFERENCES agents(node_id), + protocol TEXT NOT NULL, + port INTEGER NOT NULL, + ttl_minutes INTEGER NOT NULL, + reason TEXT NOT NULL, + desired_state TEXT NOT NULL, + actual_state TEXT NOT NULL, + version INTEGER NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NULL, + updated_at TEXT NOT NULL, + last_error TEXT NULL + ); + CREATE UNIQUE INDEX IF NOT EXISTS ux_links_active_policy + ON links(source_node_id, target_node_id, protocol, port) + WHERE desired_state = 'Active'; """; await command.ExecuteNonQueryAsync(cancellationToken); } @@ -87,6 +119,100 @@ public sealed class ControlStore(IOptions options) return token; } + public async Task CreateDeviceEnrollmentTokenAsync( + string deviceId, + TimeSpan lifetime, + CancellationToken cancellationToken = default) + { + var token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)) + .TrimEnd('=').Replace('+', '-').Replace('/', '_'); + await using var connection = await OpenAsync(cancellationToken); + var command = connection.CreateCommand(); + command.CommandText = """ + INSERT INTO device_tokens(token_hash, device_id, expires_at) + VALUES ($hash, $device, $expires); + """; + command.Parameters.AddWithValue("$hash", Hash(token)); + command.Parameters.AddWithValue("$device", deviceId); + command.Parameters.AddWithValue("$expires", DateTimeOffset.UtcNow.Add(lifetime).ToString("O")); + await command.ExecuteNonQueryAsync(cancellationToken); + return token; + } + + public async Task EnrollDeviceAsync( + DeviceEnrollmentRequest request, + Func issueCertificate, + CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken); + await using var transaction = (SqliteTransaction)await connection.BeginTransactionAsync(cancellationToken); + var cached = await ReadIdempotentAsync( + connection, + transaction, + $"device-enroll:{request.IdempotencyKey}", + Fingerprint(request, SmmJsonContext.Default.DeviceEnrollmentRequest), + SmmJsonContext.Default.DeviceEnrollmentResponse, + cancellationToken); + if (cached is not null) + { + await transaction.CommitAsync(cancellationToken); + return cached; + } + + var now = DateTimeOffset.UtcNow.ToString("O"); + var consume = connection.CreateCommand(); + consume.Transaction = transaction; + consume.CommandText = """ + UPDATE device_tokens + SET consumed_at = $now + WHERE token_hash = $hash + AND device_id = $device + AND consumed_at IS NULL + AND expires_at >= $now; + """; + consume.Parameters.AddWithValue("$now", now); + consume.Parameters.AddWithValue("$hash", Hash(request.Token)); + consume.Parameters.AddWithValue("$device", request.DeviceId); + if (await consume.ExecuteNonQueryAsync(cancellationToken) != 1) + { + await transaction.RollbackAsync(cancellationToken); + return null; + } + + var issued = issueCertificate(); + var response = new DeviceEnrollmentResponse( + request.DeviceId, + issued.CertificatePem, + issued.CertificateAuthorityPem, + issued.ExpiresAt); + var upsert = connection.CreateCommand(); + upsert.Transaction = transaction; + upsert.CommandText = """ + INSERT INTO devices(device_id, certificate_thumbprint, certificate_expires_at, status) + VALUES ($device, $thumbprint, $expires, 'Active') + ON CONFLICT(device_id) DO UPDATE SET + certificate_thumbprint = excluded.certificate_thumbprint, + certificate_expires_at = excluded.certificate_expires_at, + status = 'Active'; + """; + upsert.Parameters.AddWithValue("$device", request.DeviceId); + upsert.Parameters.AddWithValue("$thumbprint", issued.Thumbprint); + upsert.Parameters.AddWithValue("$expires", issued.ExpiresAt.ToString("O")); + await upsert.ExecuteNonQueryAsync(cancellationToken); + await WriteIdempotentAsync( + connection, + transaction, + $"device-enroll:{request.IdempotencyKey}", + Fingerprint(request, SmmJsonContext.Default.DeviceEnrollmentRequest), + response, + SmmJsonContext.Default.DeviceEnrollmentResponse, + cancellationToken); + await WriteAuditAsync( + connection, transaction, request.DeviceId, "device.enroll", request.DeviceId, "{}", cancellationToken); + await transaction.CommitAsync(cancellationToken); + return response; + } + public async Task EnrollAsync( EnrollmentRequest request, Func issueCertificate, @@ -164,19 +290,34 @@ public sealed class ControlStore(IOptions options) public async Task IsCertificateActiveAsync( string thumbprint, CancellationToken cancellationToken = default) + { + return await ResolveIdentityAsync(thumbprint, cancellationToken) is not null; + } + + public async Task ResolveIdentityAsync( + string thumbprint, + CancellationToken cancellationToken = default) { await using var connection = await OpenAsync(cancellationToken); var command = connection.CreateCommand(); command.CommandText = """ - SELECT EXISTS( - SELECT 1 FROM agents - WHERE certificate_thumbprint = $thumbprint - AND certificate_expires_at > $now - AND status != 'Revoked'); + SELECT node_id, 'Agent' FROM agents + WHERE certificate_thumbprint = $thumbprint + AND certificate_expires_at > $now + AND status != 'Revoked' + UNION ALL + SELECT device_id, 'Operator' FROM devices + WHERE certificate_thumbprint = $thumbprint + AND certificate_expires_at > $now + AND status != 'Revoked' + LIMIT 1; """; command.Parameters.AddWithValue("$thumbprint", thumbprint); command.Parameters.AddWithValue("$now", DateTimeOffset.UtcNow.ToString("O")); - return Convert.ToInt32(await command.ExecuteScalarAsync(cancellationToken)) == 1; + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + return await reader.ReadAsync(cancellationToken) + ? new ControlIdentity(reader.GetString(0), reader.GetString(1)) + : null; } public async Task IsCertificateForNodeAsync( @@ -262,6 +403,284 @@ public sealed class ControlStore(IOptions options) return response; } + public async Task> ListAgentsAsync(CancellationToken cancellationToken = default) + { + var result = new List(); + await using var connection = await OpenAsync(cancellationToken); + var command = connection.CreateCommand(); + command.CommandText = """ + SELECT node_id, name, status, agent_version, last_seen_at + FROM agents ORDER BY name; + """; + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + result.Add(new AgentSummary( + reader.GetString(0), + reader.GetString(1), + reader.GetString(2), + reader.GetString(3), + reader.IsDBNull(4) ? null : DateTimeOffset.Parse(reader.GetString(4)))); + } + return result; + } + + public async Task CreateLinkMutationAsync( + LinkPolicyCreateRequest request, + string actor, + CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken); + await using var transaction = (SqliteTransaction)await connection.BeginTransactionAsync(cancellationToken); + var cached = await ReadIdempotentAsync( + connection, + transaction, + $"link-create:{actor}:{request.IdempotencyKey}", + Fingerprint(request, SmmJsonContext.Default.LinkPolicyCreateRequest), + SmmJsonContext.Default.LinkPolicy, + cancellationToken); + if (cached is not null) + { + var current = await ReadLinkAsync(connection, transaction, cached.Id, cancellationToken) ?? cached; + await transaction.CommitAsync(cancellationToken); + return new LinkMutation(current, true); + } + + var nodes = connection.CreateCommand(); + nodes.Transaction = transaction; + nodes.CommandText = """ + SELECT COUNT(*) FROM agents + WHERE node_id IN ($source, $target) AND status != 'Revoked'; + """; + nodes.Parameters.AddWithValue("$source", request.SourceNodeId); + nodes.Parameters.AddWithValue("$target", request.TargetNodeId); + if (Convert.ToInt32(await nodes.ExecuteScalarAsync(cancellationToken)) != 2) + { + throw new InvalidOperationException("Source or target agent is not registered."); + } + + var now = DateTimeOffset.UtcNow; + DateTimeOffset? expiresAt = request.TtlMinutes == 0 + ? null + : now.AddMinutes(request.TtlMinutes); + var versionCommand = connection.CreateCommand(); + versionCommand.Transaction = transaction; + versionCommand.CommandText = "SELECT COALESCE(MAX(version), 0) + 1 FROM links;"; + var version = Convert.ToInt64(await versionCommand.ExecuteScalarAsync(cancellationToken)); + var link = new LinkPolicy( + Guid.NewGuid().ToString("N"), + request.SourceNodeId, + request.TargetNodeId, + request.Protocol, + request.Port, + request.TtlMinutes, + request.Reason, + "Active", + "Connecting", + version, + now, + expiresAt, + now, + null); + var insert = connection.CreateCommand(); + insert.Transaction = transaction; + insert.CommandText = """ + INSERT INTO links( + id, source_node_id, target_node_id, protocol, port, ttl_minutes, reason, + desired_state, actual_state, version, created_at, expires_at, updated_at, last_error) + VALUES ( + $id, $source, $target, $protocol, $port, $ttl, $reason, + $desired, $actual, $version, $created, $expires, $updated, NULL); + """; + AddLinkParameters(insert, link); + await insert.ExecuteNonQueryAsync(cancellationToken); + await WriteIdempotentAsync( + connection, + transaction, + $"link-create:{actor}:{request.IdempotencyKey}", + Fingerprint(request, SmmJsonContext.Default.LinkPolicyCreateRequest), + link, + SmmJsonContext.Default.LinkPolicy, + cancellationToken); + await WriteAuditAsync( + connection, + transaction, + actor, + "link.connect.requested", + link.Id, + JsonSerializer.Serialize(link, SmmJsonContext.Default.LinkPolicy), + cancellationToken); + await transaction.CommitAsync(cancellationToken); + return new LinkMutation(link, false); + } + + public async Task BeginDisableLinkMutationAsync( + string id, + LinkPolicyDisableRequest request, + string actor, + CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken); + await using var transaction = (SqliteTransaction)await connection.BeginTransactionAsync(cancellationToken); + var cached = await ReadIdempotentAsync( + connection, + transaction, + $"link-disable:{actor}:{id}:{request.IdempotencyKey}", + Fingerprint(request, SmmJsonContext.Default.LinkPolicyDisableRequest), + SmmJsonContext.Default.LinkPolicy, + cancellationToken); + if (cached is not null) + { + var current = await ReadLinkAsync(connection, transaction, cached.Id, cancellationToken) ?? cached; + await transaction.CommitAsync(cancellationToken); + return new LinkMutation(current, true); + } + + var existing = await ReadLinkAsync(connection, transaction, id, cancellationToken); + if (existing is null) + { + await transaction.RollbackAsync(cancellationToken); + return null; + } + var now = DateTimeOffset.UtcNow; + var link = existing with + { + DesiredState = "Disabled", + ActualState = "Disconnecting", + Version = existing.Version + 1, + UpdatedAt = now, + LastError = null + }; + var update = connection.CreateCommand(); + update.Transaction = transaction; + update.CommandText = """ + UPDATE links SET + desired_state = $desired, + actual_state = $actual, + version = $version, + updated_at = $updated, + last_error = NULL + WHERE id = $id; + """; + AddLinkParameters(update, link); + await update.ExecuteNonQueryAsync(cancellationToken); + await WriteIdempotentAsync( + connection, + transaction, + $"link-disable:{actor}:{id}:{request.IdempotencyKey}", + Fingerprint(request, SmmJsonContext.Default.LinkPolicyDisableRequest), + link, + SmmJsonContext.Default.LinkPolicy, + cancellationToken); + await WriteAuditAsync( + connection, transaction, actor, "link.disconnect.requested", id, "{}", cancellationToken); + await transaction.CommitAsync(cancellationToken); + return new LinkMutation(link, false); + } + + public async Task SetLinkActualStateAsync( + string id, + string state, + string? error, + string actor, + CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken); + await using var transaction = (SqliteTransaction)await connection.BeginTransactionAsync(cancellationToken); + var update = connection.CreateCommand(); + update.Transaction = transaction; + update.CommandText = """ + UPDATE links SET actual_state = $state, last_error = $error, updated_at = $now + WHERE id = $id; + """; + update.Parameters.AddWithValue("$state", state); + update.Parameters.AddWithValue("$error", (object?)error ?? DBNull.Value); + update.Parameters.AddWithValue("$now", DateTimeOffset.UtcNow.ToString("O")); + update.Parameters.AddWithValue("$id", id); + if (await update.ExecuteNonQueryAsync(cancellationToken) != 1) + { + await transaction.RollbackAsync(cancellationToken); + return null; + } + var link = await ReadLinkAsync(connection, transaction, id, cancellationToken); + await WriteAuditAsync( + connection, + transaction, + actor, + $"link.state.{state.ToLowerInvariant()}", + id, + error is null + ? "{}" + : JsonSerializer.Serialize(new ControlError(error), SmmJsonContext.Default.ControlError), + cancellationToken); + await transaction.CommitAsync(cancellationToken); + return link; + } + + public async Task> ListLinksAsync(CancellationToken cancellationToken = default) + { + var result = new List(); + await using var connection = await OpenAsync(cancellationToken); + var command = connection.CreateCommand(); + command.CommandText = "SELECT * FROM links ORDER BY created_at DESC;"; + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + result.Add(ReadLink(reader)); + } + return result; + } + + private static void AddLinkParameters(SqliteCommand command, LinkPolicy link) + { + command.Parameters.AddWithValue("$id", link.Id); + command.Parameters.AddWithValue("$source", link.SourceNodeId); + command.Parameters.AddWithValue("$target", link.TargetNodeId); + command.Parameters.AddWithValue("$protocol", link.Protocol); + command.Parameters.AddWithValue("$port", link.Port); + command.Parameters.AddWithValue("$ttl", link.TtlMinutes); + command.Parameters.AddWithValue("$reason", link.Reason); + command.Parameters.AddWithValue("$desired", link.DesiredState); + command.Parameters.AddWithValue("$actual", link.ActualState); + command.Parameters.AddWithValue("$version", link.Version); + command.Parameters.AddWithValue("$created", link.CreatedAt.ToString("O")); + command.Parameters.AddWithValue( + "$expires", + link.ExpiresAt is null ? DBNull.Value : link.ExpiresAt.Value.ToString("O")); + command.Parameters.AddWithValue("$updated", link.UpdatedAt.ToString("O")); + } + + private static async Task ReadLinkAsync( + SqliteConnection connection, + SqliteTransaction transaction, + string id, + CancellationToken cancellationToken) + { + var command = connection.CreateCommand(); + command.Transaction = transaction; + command.CommandText = "SELECT * FROM links WHERE id = $id;"; + command.Parameters.AddWithValue("$id", id); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + return await reader.ReadAsync(cancellationToken) ? ReadLink(reader) : null; + } + + private static LinkPolicy ReadLink(SqliteDataReader reader) + => new( + reader.GetString(0), + reader.GetString(1), + reader.GetString(2), + reader.GetString(3), + reader.GetInt32(4), + reader.GetInt32(5), + reader.GetString(6), + reader.GetString(7), + reader.GetString(8), + reader.GetInt64(9), + DateTimeOffset.Parse(reader.GetString(10)), + reader.IsDBNull(11) ? null : DateTimeOffset.Parse(reader.GetString(11)), + DateTimeOffset.Parse(reader.GetString(12)), + reader.IsDBNull(13) ? null : reader.GetString(13)); + private async Task OpenAsync(CancellationToken cancellationToken) { var connection = new SqliteConnection(_connectionString); @@ -352,3 +771,7 @@ public sealed class IdempotencyConflictException : Exception { } } + +public sealed record ControlIdentity(string Id, string Role); + +public sealed record LinkMutation(LinkPolicy Link, bool IsReplay); diff --git a/src/ServerMonitorManager.Control/LinkPolicyApplier.cs b/src/ServerMonitorManager.Control/LinkPolicyApplier.cs new file mode 100644 index 0000000..44e0388 --- /dev/null +++ b/src/ServerMonitorManager.Control/LinkPolicyApplier.cs @@ -0,0 +1,69 @@ +using System.Diagnostics; +using Microsoft.Extensions.Options; +using ServerMonitorManager.Core; + +namespace ServerMonitorManager.Control; + +public interface ILinkPolicyApplier +{ + Task ApplyConnectAsync(LinkPolicy link, CancellationToken cancellationToken); + Task ApplyDisconnectAsync(LinkPolicy link, CancellationToken cancellationToken); +} + +public sealed class LinkPolicyApplier(IOptions options) : ILinkPolicyApplier +{ + public Task ApplyConnectAsync(LinkPolicy link, CancellationToken cancellationToken) + => RunAsync( + [ + "link-connect", + link.SourceNodeId, + link.TargetNodeId, + link.Protocol, + link.Port.ToString(System.Globalization.CultureInfo.InvariantCulture), + link.TtlMinutes.ToString(System.Globalization.CultureInfo.InvariantCulture) + ], + cancellationToken); + + public Task ApplyDisconnectAsync(LinkPolicy link, CancellationToken cancellationToken) + => RunAsync( + [ + "link-disconnect", + link.SourceNodeId, + link.TargetNodeId, + link.Protocol, + link.Port.ToString(System.Globalization.CultureInfo.InvariantCulture) + ], + cancellationToken); + + private async Task RunAsync(IReadOnlyList arguments, CancellationToken cancellationToken) + { + var startInfo = new ProcessStartInfo + { + FileName = "/usr/bin/sudo", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + startInfo.ArgumentList.Add("-n"); + startInfo.ArgumentList.Add(options.Value.HubHelperPath); + foreach (var argument in arguments) + { + startInfo.ArgumentList.Add(argument); + } + + using var process = Process.Start(startInfo) + ?? throw new InvalidOperationException("Could not start the Hub policy helper."); + var output = process.StandardOutput.ReadToEndAsync(cancellationToken); + var error = process.StandardError.ReadToEndAsync(cancellationToken); + await process.WaitForExitAsync(cancellationToken); + if (process.ExitCode != 0) + { + var message = (await error).Trim(); + throw new InvalidOperationException(string.IsNullOrWhiteSpace(message) + ? $"Hub policy helper exited with code {process.ExitCode}." + : message); + } + _ = await output; + } +} diff --git a/src/ServerMonitorManager.Control/LinkService.cs b/src/ServerMonitorManager.Control/LinkService.cs new file mode 100644 index 0000000..d6aa972 --- /dev/null +++ b/src/ServerMonitorManager.Control/LinkService.cs @@ -0,0 +1,82 @@ +using System.Text.Json; +using ServerMonitorManager.Core; + +namespace ServerMonitorManager.Control; + +public sealed class LinkService( + ControlStore store, + ILinkPolicyApplier applier, + ControlEventBroker events) +{ + public async Task CreateAsync( + LinkPolicyCreateRequest request, + string actor, + CancellationToken cancellationToken) + { + var mutation = await store.CreateLinkMutationAsync(request, actor, cancellationToken); + var link = mutation.Link; + if (mutation.IsReplay) + { + return link; + } + Publish("link.connecting", link); + try + { + await applier.ApplyConnectAsync(link, cancellationToken); + link = await store.SetLinkActualStateAsync(link.Id, "Active", null, actor, cancellationToken) + ?? throw new InvalidOperationException("The persisted Link disappeared."); + Publish("link.active", link); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + link = await store.SetLinkActualStateAsync( + link.Id, "Failed", CompactError(exception), actor, cancellationToken) + ?? link; + Publish("link.failed", link); + } + return link; + } + + public async Task DisableAsync( + string id, + LinkPolicyDisableRequest request, + string actor, + CancellationToken cancellationToken) + { + var mutation = await store.BeginDisableLinkMutationAsync(id, request, actor, cancellationToken); + if (mutation is null) + { + return null; + } + var link = mutation.Link; + if (mutation.IsReplay) + { + return link; + } + Publish("link.disconnecting", link); + try + { + await applier.ApplyDisconnectAsync(link, cancellationToken); + link = await store.SetLinkActualStateAsync(id, "Disabled", null, actor, cancellationToken) ?? link; + Publish("link.disabled", link); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + link = await store.SetLinkActualStateAsync( + id, "Partial", CompactError(exception), actor, cancellationToken) + ?? link; + Publish("link.partial", link); + } + return link; + } + + private void Publish(string type, LinkPolicy link) + => events.Publish( + type, + link.Id, + JsonSerializer.Serialize(link, SmmJsonContext.Default.LinkPolicy)); + + private static string CompactError(Exception exception) + => exception.Message.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries) + .FirstOrDefault() ?? "Policy application failed."; +} diff --git a/src/ServerMonitorManager.Control/Program.cs b/src/ServerMonitorManager.Control/Program.cs index 30a5a67..e4a4121 100644 --- a/src/ServerMonitorManager.Control/Program.cs +++ b/src/ServerMonitorManager.Control/Program.cs @@ -1,10 +1,12 @@ using System.Security.Claims; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; +using System.Text.Json; using System.Threading.RateLimiting; using Microsoft.AspNetCore.Authentication.Certificate; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Server.Kestrel.Https; +using Microsoft.Data.Sqlite; using Microsoft.Extensions.Options; using ServerMonitorManager.Control; using ServerMonitorManager.Core; @@ -37,6 +39,9 @@ builder.Services.AddOptions() .ValidateOnStart(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddAuthentication(CertificateAuthenticationDefaults.AuthenticationScheme) .AddCertificate(options => { @@ -49,14 +54,18 @@ builder.Services.AddAuthentication(CertificateAuthenticationDefaults.Authenticat OnCertificateValidated = async context => { var store = context.HttpContext.RequestServices.GetRequiredService(); - if (!await store.IsCertificateActiveAsync(context.ClientCertificate.Thumbprint)) + var identity = await store.ResolveIdentityAsync(context.ClientCertificate.Thumbprint); + if (identity is null) { context.Fail("The agent certificate is unknown, expired, or revoked."); return; } context.Principal = new ClaimsPrincipal(new ClaimsIdentity( - [new Claim(ClaimTypes.NameIdentifier, context.ClientCertificate.Thumbprint)], + [ + new Claim(ClaimTypes.NameIdentifier, identity.Id), + new Claim(ClaimTypes.Role, identity.Role) + ], context.Scheme.Name)); context.Success(); } @@ -69,7 +78,11 @@ builder.Services.AddOptions( options.ChainTrustValidationMode = X509ChainTrustMode.CustomRootTrust; options.CustomTrustStore.Add(authority.PublicCertificate); }); -builder.Services.AddAuthorization(); +builder.Services.AddAuthorization(options => +{ + options.AddPolicy("Agent", policy => policy.RequireRole("Agent")); + options.AddPolicy("Operator", policy => policy.RequireRole("Operator")); +}); builder.WebHost.ConfigureKestrel(options => { options.Limits.MaxRequestBodySize = 64 * 1024; @@ -99,6 +112,18 @@ if (args is ["token-create", var nodeId]) return 0; } +if (args is ["device-token-create", var deviceId]) +{ + if (!NodeIdValidator.IsValid(deviceId)) + { + Console.Error.WriteLine("Device id must contain 1-63 lowercase letters, digits, or hyphens."); + return 2; + } + + Console.WriteLine(await store.CreateDeviceEnrollmentTokenAsync(deviceId, TimeSpan.FromMinutes(10))); + return 0; +} + app.MapHealthChecks("/healthz").AllowAnonymous(); app.MapPost("/api/v1/enroll", async ( @@ -147,7 +172,49 @@ app.MapPost("/api/v1/enroll", async ( : Results.Ok(response); }).AllowAnonymous().RequireRateLimiting("enrollment"); -var agents = app.MapGroup("/api/v1/agents").RequireAuthorization(); +app.MapPost("/api/v1/device-enroll", async ( + DeviceEnrollmentRequest request, + ControlStore controlStore, + CertificateAuthority authority, + CancellationToken cancellationToken) => +{ + if (!NodeIdValidator.IsValid(request.DeviceId) + || string.IsNullOrWhiteSpace(request.Token) + || string.IsNullOrWhiteSpace(request.CertificateSigningRequestPem) + || !IdempotencyKeyValidator.IsValid(request.IdempotencyKey)) + { + return Results.ValidationProblem(new Dictionary + { + ["request"] = ["Invalid device enrollment request."] + }); + } + + try + { + var response = await controlStore.EnrollDeviceAsync( + request, + () => authority.IssueClientCertificate(request.DeviceId, request.CertificateSigningRequestPem), + cancellationToken); + return response is null ? Results.Unauthorized() : Results.Ok(response); + } + catch (IdempotencyConflictException) + { + return Results.Conflict(new ProblemDetails + { + Title = "Idempotency key conflict", + Status = StatusCodes.Status409Conflict + }); + } + catch (Exception exception) when (exception is CryptographicException or InvalidOperationException) + { + return Results.ValidationProblem(new Dictionary + { + ["certificateSigningRequestPem"] = ["Invalid certificate signing request."] + }); + } +}).AllowAnonymous().RequireRateLimiting("enrollment"); + +var agents = app.MapGroup("/api/v1/agents").RequireAuthorization("Agent"); agents.MapPost("/heartbeat", async ( AgentHeartbeat heartbeat, HttpContext context, @@ -186,6 +253,11 @@ agents.MapPost("/heartbeat", async ( heartbeat, options.Value.HeartbeatSeconds, cancellationToken); + var broker = context.RequestServices.GetRequiredService(); + broker.Publish( + "agent.heartbeat", + heartbeat.NodeId, + JsonSerializer.Serialize(heartbeat, SmmJsonContext.Default.AgentHeartbeat)); return Results.Ok(response); } catch (IdempotencyConflictException) @@ -198,6 +270,86 @@ agents.MapPost("/heartbeat", async ( } }); +var control = app.MapGroup("/api/v1/control").RequireAuthorization("Operator"); +control.MapGet("/agents", async (ControlStore controlStore, CancellationToken cancellationToken) => + Results.Ok((await controlStore.ListAgentsAsync(cancellationToken)).ToArray())); +control.MapGet("/links", async (ControlStore controlStore, CancellationToken cancellationToken) => + Results.Ok((await controlStore.ListLinksAsync(cancellationToken)).ToArray())); +control.MapPost("/links", async ( + LinkPolicyCreateRequest request, + HttpContext context, + LinkService linkService, + CancellationToken cancellationToken) => +{ + if (!LinkPolicyValidator.IsValid(request)) + { + return Results.ValidationProblem(new Dictionary + { + ["link"] = ["Invalid source, target, protocol, port, TTL, reason, or idempotency key."] + }); + } + var actor = context.User.FindFirstValue(ClaimTypes.NameIdentifier)!; + try + { + var link = await linkService.CreateAsync(request, actor, cancellationToken); + return Results.Created($"/api/v1/control/links/{link.Id}", link); + } + catch (IdempotencyConflictException) + { + return Results.Conflict(new ProblemDetails { Title = "Idempotency key conflict" }); + } + catch (SqliteException exception) when (exception.SqliteErrorCode == 19) + { + return Results.Conflict(new ProblemDetails { Title = "An active Link already exists." }); + } + catch (InvalidOperationException exception) + { + return Results.BadRequest(new ProblemDetails { Title = exception.Message }); + } +}); +control.MapPost("/links/{id}/disable", async ( + string id, + LinkPolicyDisableRequest request, + HttpContext context, + LinkService linkService, + CancellationToken cancellationToken) => +{ + if (id.Length != 32 || !Guid.TryParseExact(id, "N", out _) + || !IdempotencyKeyValidator.IsValid(request.IdempotencyKey)) + { + return Results.ValidationProblem(new Dictionary + { + ["link"] = ["Invalid Link id or idempotency key."] + }); + } + var actor = context.User.FindFirstValue(ClaimTypes.NameIdentifier)!; + try + { + var link = await linkService.DisableAsync(id, request, actor, cancellationToken); + return link is null ? Results.NotFound() : Results.Ok(link); + } + catch (IdempotencyConflictException) + { + return Results.Conflict(new ProblemDetails { Title = "Idempotency key conflict" }); + } +}); +control.MapGet("/events", async (HttpContext context, ControlEventBroker broker) => +{ + context.Response.ContentType = "application/x-ndjson"; + context.Response.Headers.CacheControl = "no-store"; + using var subscription = broker.Subscribe(); + await foreach (var controlEvent in subscription.Reader.ReadAllAsync(context.RequestAborted)) + { + await JsonSerializer.SerializeAsync( + context.Response.Body, + controlEvent, + SmmJsonContext.Default.ControlEvent, + context.RequestAborted); + await context.Response.WriteAsync("\n", context.RequestAborted); + await context.Response.Body.FlushAsync(context.RequestAborted); + } +}); + await app.RunAsync(); return 0; @@ -215,3 +367,16 @@ internal static class IdempotencyKeyValidator public static bool IsValid(string value) => Guid.TryParse(value, out _); } + +internal static class LinkPolicyValidator +{ + public static bool IsValid(LinkPolicyCreateRequest request) + => NodeIdValidator.IsValid(request.SourceNodeId) + && NodeIdValidator.IsValid(request.TargetNodeId) + && request.SourceNodeId != request.TargetNodeId + && request.Protocol is "tcp" or "udp" + && request.Port is >= 1 and <= 65535 + && request.TtlMinutes is >= 0 and <= 525600 + && request.Reason.Length <= 256 + && IdempotencyKeyValidator.IsValid(request.IdempotencyKey); +} diff --git a/src/ServerMonitorManager.Control/appsettings.json b/src/ServerMonitorManager.Control/appsettings.json index 5df2287..58b8d0e 100644 --- a/src/ServerMonitorManager.Control/appsettings.json +++ b/src/ServerMonitorManager.Control/appsettings.json @@ -3,7 +3,8 @@ "DatabasePath": "/var/lib/ochenstarik-server-monitor-manager/control.db", "CertificateAuthorityPath": "/etc/ochenstarik-server-monitor-manager/control-ca.pfx", "CertificateAuthorityPassword": null, - "HeartbeatSeconds": 30 + "HeartbeatSeconds": 30, + "HubHelperPath": "/usr/local/libexec/ochenstarik-smm-policy-apply" }, "Logging": { "LogLevel": { diff --git a/src/ServerMonitorManager.Core/Contracts.cs b/src/ServerMonitorManager.Core/Contracts.cs index fb8452c..59aa326 100644 --- a/src/ServerMonitorManager.Core/Contracts.cs +++ b/src/ServerMonitorManager.Core/Contracts.cs @@ -37,3 +37,51 @@ public sealed record AgentSummary( string Status, string AgentVersion, DateTimeOffset? LastSeenAt); + +public sealed record DeviceEnrollmentRequest( + string DeviceId, + string Token, + string CertificateSigningRequestPem, + string IdempotencyKey); + +public sealed record DeviceEnrollmentResponse( + string DeviceId, + string CertificatePem, + string CertificateAuthorityPem, + DateTimeOffset ExpiresAt); + +public sealed record LinkPolicyCreateRequest( + string SourceNodeId, + string TargetNodeId, + string Protocol, + int Port, + int TtlMinutes, + string Reason, + string IdempotencyKey); + +public sealed record LinkPolicyDisableRequest(string IdempotencyKey); + +public sealed record LinkPolicy( + string Id, + string SourceNodeId, + string TargetNodeId, + string Protocol, + int Port, + int TtlMinutes, + string Reason, + string DesiredState, + string ActualState, + long Version, + DateTimeOffset CreatedAt, + DateTimeOffset? ExpiresAt, + DateTimeOffset UpdatedAt, + string? LastError); + +public sealed record ControlEvent( + long Sequence, + string Type, + string Subject, + DateTimeOffset RecordedAt, + string PayloadJson); + +public sealed record ControlError(string Error); diff --git a/src/ServerMonitorManager.Core/SmmJsonContext.cs b/src/ServerMonitorManager.Core/SmmJsonContext.cs index f125bb6..520cacc 100644 --- a/src/ServerMonitorManager.Core/SmmJsonContext.cs +++ b/src/ServerMonitorManager.Core/SmmJsonContext.cs @@ -7,4 +7,12 @@ namespace ServerMonitorManager.Core; [JsonSerializable(typeof(AgentHeartbeat))] [JsonSerializable(typeof(AgentHeartbeatResponse))] [JsonSerializable(typeof(AgentSummary[]))] +[JsonSerializable(typeof(DeviceEnrollmentRequest))] +[JsonSerializable(typeof(DeviceEnrollmentResponse))] +[JsonSerializable(typeof(LinkPolicyCreateRequest))] +[JsonSerializable(typeof(LinkPolicyDisableRequest))] +[JsonSerializable(typeof(LinkPolicy))] +[JsonSerializable(typeof(LinkPolicy[]))] +[JsonSerializable(typeof(ControlEvent))] +[JsonSerializable(typeof(ControlError))] public sealed partial class SmmJsonContext : JsonSerializerContext; diff --git a/src/ServerMonitorManager.Desktop/ControlClientService.cs b/src/ServerMonitorManager.Desktop/ControlClientService.cs new file mode 100644 index 0000000..eff3be0 --- /dev/null +++ b/src/ServerMonitorManager.Desktop/ControlClientService.cs @@ -0,0 +1,318 @@ +using System.Net.Http.Json; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using ServerMonitorManager.Core; +using Windows.Storage; + +namespace ServerMonitorManager_Desktop; + +public sealed record ControlEnrollmentPreview( + string DeviceId, + Uri ControlUrl, + string Token, + byte[] CertificateAuthority, + string Fingerprint); + +public sealed partial class ControlClientService +{ + private const string FolderName = "control"; + private const string ProtectedCertificateName = "device.pfx.dpapi"; + private const string CertificateAuthorityName = "control-ca.crt"; + private const string ConfigurationName = "control.conf"; + + public bool IsConfigured + { + get + { + var folder = Path.Combine(ApplicationData.Current.LocalFolder.Path, FolderName); + return File.Exists(Path.Combine(folder, ConfigurationName)) + && File.Exists(Path.Combine(folder, ProtectedCertificateName)) + && File.Exists(Path.Combine(folder, CertificateAuthorityName)); + } + } + + public ControlEnrollmentPreview ParseEnrollmentCode(string code) + { + code = code.Trim().Replace("\r", string.Empty, StringComparison.Ordinal); + if (!code.StartsWith("SMMDEV1-", StringComparison.Ordinal)) + { + throw new InvalidOperationException("Ожидается код формата SMMDEV1-..."); + } + + var encoded = code[8..].Replace('-', '+').Replace('_', '/'); + encoded += (encoded.Length % 4) switch + { + 2 => "==", + 3 => "=", + 0 => string.Empty, + _ => throw new InvalidOperationException("Некорректный код устройства.") + }; + string payload; + try + { + payload = Encoding.UTF8.GetString(Convert.FromBase64String(encoded)); + } + catch (FormatException) + { + throw new InvalidOperationException("Не удалось декодировать код устройства."); + } + + var values = payload.Split('\n', StringSplitOptions.RemoveEmptyEntries) + .Select(line => line.Split('=', 2)) + .Where(parts => parts.Length == 2) + .ToDictionary(parts => parts[0], parts => parts[1], StringComparer.Ordinal); + if (values.GetValueOrDefault("VERSION") != "1" + || !DeviceIdRegex().IsMatch(values.GetValueOrDefault("DEVICE", string.Empty)) + || !TokenRegex().IsMatch(values.GetValueOrDefault("TOKEN", string.Empty)) + || !Uri.TryCreate(values.GetValueOrDefault("URL"), UriKind.Absolute, out var controlUrl) + || controlUrl.Scheme != Uri.UriSchemeHttps) + { + throw new InvalidOperationException("Поля SMMDEV1 не прошли проверку."); + } + + byte[] certificateAuthority; + try + { + certificateAuthority = Convert.FromBase64String(values["CA"]); + } + catch (Exception exception) when (exception is FormatException or KeyNotFoundException) + { + throw new InvalidOperationException("В коде отсутствует корректный Control CA."); + } + using var certificate = X509CertificateLoader.LoadCertificate(certificateAuthority); + var now = DateTimeOffset.UtcNow; + if (now < certificate.NotBefore.ToUniversalTime() || now >= certificate.NotAfter.ToUniversalTime()) + { + throw new InvalidOperationException("Сертификат Control CA сейчас недействителен."); + } + var fingerprint = Convert.ToHexString(SHA256.HashData(certificate.RawData)); + fingerprint = string.Join(':', Enumerable.Range(0, fingerprint.Length / 2) + .Select(index => fingerprint.Substring(index * 2, 2))); + return new ControlEnrollmentPreview( + values["DEVICE"], controlUrl, values["TOKEN"], certificateAuthority, fingerprint); + } + + public async Task EnrollAsync( + ControlEnrollmentPreview preview, + CancellationToken cancellationToken = default) + { + using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256); + var certificateRequest = new CertificateRequest( + $"CN={preview.DeviceId}", key, HashAlgorithmName.SHA256); + var request = new DeviceEnrollmentRequest( + preview.DeviceId, + preview.Token, + certificateRequest.CreateSigningRequestPem(), + Guid.NewGuid().ToString()); + using var client = CreateHttpClient(preview.ControlUrl, preview.CertificateAuthority, null); + using var response = await client.PostAsJsonAsync( + "api/v1/device-enroll", + request, + SmmJsonContext.Default.DeviceEnrollmentRequest, + cancellationToken); + response.EnsureSuccessStatusCode(); + var enrollment = await response.Content.ReadFromJsonAsync( + SmmJsonContext.Default.DeviceEnrollmentResponse, + cancellationToken) ?? throw new InvalidOperationException("Control Hub вернул пустой ответ регистрации."); + using var certificate = X509Certificate2.CreateFromPem( + enrollment.CertificatePem, + key.ExportPkcs8PrivateKeyPem()); + var pfx = certificate.Export(X509ContentType.Pfx); + try + { + var protectedPfx = ProtectedData.Protect(pfx, null, DataProtectionScope.CurrentUser); + var folder = await ApplicationData.Current.LocalFolder.CreateFolderAsync( + FolderName, CreationCollisionOption.OpenIfExists); + await File.WriteAllBytesAsync( + Path.Combine(folder.Path, ProtectedCertificateName), protectedPfx, cancellationToken); + await File.WriteAllBytesAsync( + Path.Combine(folder.Path, CertificateAuthorityName), preview.CertificateAuthority, cancellationToken); + await File.WriteAllLinesAsync( + Path.Combine(folder.Path, ConfigurationName), + [preview.ControlUrl.AbsoluteUri, preview.DeviceId], + cancellationToken); + } + finally + { + CryptographicOperations.ZeroMemory(pfx); + } + } + + public async Task ListenAsync( + Func onEvent, + CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + try + { + using var session = await CreateAuthenticatedSessionAsync(cancellationToken); + if (session is null) + { + return; + } + using var request = new HttpRequestMessage(HttpMethod.Get, "api/v1/control/events"); + using var response = await session.Client.SendAsync( + request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + response.EnsureSuccessStatusCode(); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + using var reader = new StreamReader(stream, Encoding.UTF8); + while (!cancellationToken.IsCancellationRequested) + { + var line = await reader.ReadLineAsync(cancellationToken); + if (line is null) + { + break; + } + var controlEvent = JsonSerializer.Deserialize(line, SmmJsonContext.Default.ControlEvent); + if (controlEvent is not null) + { + await onEvent(controlEvent); + } + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + return; + } + catch + { + await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken); + } + } + } + + public async Task> GetAgentsAsync(CancellationToken cancellationToken) + { + using var session = await RequireAuthenticatedSessionAsync(cancellationToken); + return await session.Client.GetFromJsonAsync( + "api/v1/control/agents", + SmmJsonContext.Default.AgentSummaryArray, + cancellationToken) ?? []; + } + + public async Task> GetLinksAsync(CancellationToken cancellationToken) + { + using var session = await RequireAuthenticatedSessionAsync(cancellationToken); + return await session.Client.GetFromJsonAsync( + "api/v1/control/links", + SmmJsonContext.Default.LinkPolicyArray, + cancellationToken) ?? []; + } + + public async Task CreateLinkAsync( + LinkPolicyCreateRequest request, + CancellationToken cancellationToken) + { + using var session = await RequireAuthenticatedSessionAsync(cancellationToken); + using var response = await session.Client.PostAsJsonAsync( + "api/v1/control/links", + request, + SmmJsonContext.Default.LinkPolicyCreateRequest, + cancellationToken); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadFromJsonAsync( + SmmJsonContext.Default.LinkPolicy, + cancellationToken) ?? throw new InvalidOperationException("Control Hub вернул пустой Link."); + } + + public async Task DisableLinkAsync(string id, CancellationToken cancellationToken) + { + using var session = await RequireAuthenticatedSessionAsync(cancellationToken); + using var response = await session.Client.PostAsJsonAsync( + $"api/v1/control/links/{id}/disable", + new LinkPolicyDisableRequest(Guid.NewGuid().ToString()), + SmmJsonContext.Default.LinkPolicyDisableRequest, + cancellationToken); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadFromJsonAsync( + SmmJsonContext.Default.LinkPolicy, + cancellationToken) ?? throw new InvalidOperationException("Control Hub вернул пустой Link."); + } + + private static HttpClient CreateHttpClient( + Uri baseAddress, + byte[] rootBytes, + X509Certificate2? clientCertificate) + { + var handler = new HttpClientHandler(); + if (clientCertificate is not null) + { + handler.ClientCertificates.Add(clientCertificate); + } + handler.ServerCertificateCustomValidationCallback = (_, certificate, _, errors) => + { + if (certificate is null + || errors.HasFlag(System.Net.Security.SslPolicyErrors.RemoteCertificateNameMismatch) + || errors.HasFlag(System.Net.Security.SslPolicyErrors.RemoteCertificateNotAvailable)) + { + return false; + } + using var trustedRoot = X509CertificateLoader.LoadCertificate(rootBytes); + using var chain = new X509Chain(); + chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust; + chain.ChainPolicy.CustomTrustStore.Add(trustedRoot); + chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; + chain.ChainPolicy.ApplicationPolicy.Add(new Oid("1.3.6.1.5.5.7.3.1")); + return chain.Build(new X509Certificate2(certificate)); + }; + return new HttpClient(handler) { BaseAddress = baseAddress }; + } + + private static async Task CreateAuthenticatedSessionAsync( + CancellationToken cancellationToken) + { + var folder = Path.Combine(ApplicationData.Current.LocalFolder.Path, FolderName); + var configurationPath = Path.Combine(folder, ConfigurationName); + var protectedPath = Path.Combine(folder, ProtectedCertificateName); + var caPath = Path.Combine(folder, CertificateAuthorityName); + if (!File.Exists(configurationPath) || !File.Exists(protectedPath) || !File.Exists(caPath)) + { + return null; + } + var configuration = await File.ReadAllLinesAsync(configurationPath, cancellationToken); + if (configuration.Length < 1 || !Uri.TryCreate(configuration[0], UriKind.Absolute, out var controlUrl)) + { + throw new InvalidOperationException("Сохранённая конфигурация Control Hub повреждена."); + } + var protectedPfx = await File.ReadAllBytesAsync(protectedPath, cancellationToken); + var pfx = ProtectedData.Unprotect(protectedPfx, null, DataProtectionScope.CurrentUser); + try + { + var certificate = X509CertificateLoader.LoadPkcs12( + pfx, password: null, X509KeyStorageFlags.EphemeralKeySet); + var ca = await File.ReadAllBytesAsync(caPath, cancellationToken); + return new AuthenticatedControlSession( + CreateHttpClient(controlUrl, ca, certificate), certificate); + } + finally + { + CryptographicOperations.ZeroMemory(pfx); + } + } + + private static async Task RequireAuthenticatedSessionAsync( + CancellationToken cancellationToken) + => await CreateAuthenticatedSessionAsync(cancellationToken) + ?? throw new InvalidOperationException("Сначала подключите Control Hub через код SMMDEV1."); + + [GeneratedRegex("^[a-z0-9][a-z0-9-]{0,62}$", RegexOptions.CultureInvariant)] + private static partial Regex DeviceIdRegex(); + + [GeneratedRegex("^[A-Za-z0-9_-]{43}$", RegexOptions.CultureInvariant)] + private static partial Regex TokenRegex(); +} + +internal sealed class AuthenticatedControlSession(HttpClient client, X509Certificate2 certificate) : IDisposable +{ + public HttpClient Client { get; } = client; + + public void Dispose() + { + Client.Dispose(); + certificate.Dispose(); + } +} diff --git a/src/ServerMonitorManager.Desktop/MainPage.xaml b/src/ServerMonitorManager.Desktop/MainPage.xaml index fe99e56..ab1da42 100644 --- a/src/ServerMonitorManager.Desktop/MainPage.xaml +++ b/src/ServerMonitorManager.Desktop/MainPage.xaml @@ -74,6 +74,11 @@ Click="SshKeyButton_Click" Icon="Permissions" Label="SSH-ключ" /> + _history = []; private readonly DispatcherTimer _refreshTimer = new() { Interval = TimeSpan.FromSeconds(30) }; private readonly SemaphoreSlim _refreshLock = new(1, 1); + private readonly CancellationTokenSource _controlCancellation = new(); private bool _loaded; + private bool _controlListening; public MainPage() { InitializeComponent(); Loaded += MainPage_Loaded; + Unloaded += (_, _) => _controlCancellation.Cancel(); _refreshTimer.Tick += async (_, _) => await RefreshAllAsync(); MainNavigation.SelectedItem = MainNavigation.MenuItems[0]; } @@ -56,6 +61,7 @@ public sealed partial class MainPage : Page { await RefreshAllAsync(); } + StartControlEvents(); } private async void SshKeyButton_Click(object sender, RoutedEventArgs e) @@ -161,6 +167,137 @@ public sealed partial class MainPage : Page } } + private async void ControlHubButton_Click(object sender, RoutedEventArgs e) + { + var codeBox = new TextBox + { + Header = "Код устройства SMMDEV1", + PlaceholderText = "SMMDEV1-...", + AcceptsReturn = true, + TextWrapping = TextWrapping.Wrap, + MinWidth = 360 + }; + AutomationProperties.SetName(codeBox, "Одноразовый код Control Hub для Windows-устройства"); + var codeDialog = new ContentDialog + { + XamlRoot = XamlRoot, + Title = "Подключение Control Hub", + Content = new StackPanel + { + Spacing = 12, + Children = + { + new TextBlock + { + Text = "На Hub выполните: sudo ochenstarik-server-monitor-manager.sh control-device-code windows-pc. Вставьте полученный одноразовый код.", + TextWrapping = TextWrapping.Wrap + }, + codeBox + } + }, + PrimaryButtonText = "Проверить", + CloseButtonText = "Отмена", + DefaultButton = ContentDialogButton.Primary + }; + if (await codeDialog.ShowAsync() != ContentDialogResult.Primary) + { + return; + } + + try + { + var preview = _control.ParseEnrollmentCode(codeBox.Text); + var fingerprintBox = new TextBox + { + Header = "SHA-256 fingerprint Control CA", + Text = preview.Fingerprint, + IsReadOnly = true, + TextWrapping = TextWrapping.Wrap + }; + AutomationProperties.SetName(fingerprintBox, "SHA-256 fingerprint Control CA"); + var confirmDialog = new ContentDialog + { + XamlRoot = XamlRoot, + Title = "Подтвердите Control Hub", + Content = new StackPanel + { + Spacing = 12, + Children = + { + new TextBlock + { + Text = $"Устройство: {preview.DeviceId}\nHub: {preview.ControlUrl}", + TextWrapping = TextWrapping.Wrap + }, + fingerprintBox, + new TextBlock + { + Text = "Сравните fingerprint с показанным на Hub. После подтверждения приложение локально создаст отдельный operator key и защитит его через Windows DPAPI.", + TextWrapping = TextWrapping.Wrap + } + } + }, + PrimaryButtonText = "Fingerprint совпадает", + CloseButtonText = "Отмена", + DefaultButton = ContentDialogButton.Close + }; + if (await confirmDialog.ShowAsync() != ContentDialogResult.Primary) + { + return; + } + + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + await _control.EnrollAsync(preview, timeout.Token); + ShowInfo( + "Control Hub подключён", + $"Operator identity {preview.DeviceId} сохранена через DPAPI. Поток событий запускается.", + InfoBarSeverity.Success); + StartControlEvents(); + } + catch (Exception exception) + { + ShowInfo("Не удалось подключить Control Hub", CompactError(exception), InfoBarSeverity.Error); + } + } + + private void StartControlEvents() + { + if (_controlListening || _controlCancellation.IsCancellationRequested) + { + return; + } + _controlListening = true; + _ = Task.Run(async () => + { + try + { + await _control.ListenAsync(HandleControlEventAsync, _controlCancellation.Token); + } + finally + { + _controlListening = false; + } + }); + } + + private Task HandleControlEventAsync(ControlEvent controlEvent) + { + DispatcherQueue.TryEnqueue(() => + { + if (controlEvent.Type.StartsWith("link.", StringComparison.Ordinal)) + { + ShowInfo( + "Событие Control Hub", + $"{controlEvent.Subject}: {controlEvent.Type}", + controlEvent.Type is "link.failed" or "link.partial" + ? InfoBarSeverity.Warning + : InfoBarSeverity.Informational); + _ = RefreshMeshAsync(showSuccess: false); + } + }); + return Task.CompletedTask; + } + private async void AddServerButton_Click(object sender, RoutedEventArgs e) { try @@ -365,7 +502,7 @@ public sealed partial class MainPage : Page WarningValueText.Text = warnings.ToString(CultureInfo.InvariantCulture); WarningDetailText.Text = warnings == 0 ? "Нет предупреждений" : "Проверьте доступность и ресурсы"; HeaderStatusText.Text = $"SSH monitoring · {Servers.Count} сервер(а) · обновлено {DateTime.Now:HH:mm:ss}"; - if (Servers.Any(server => server.IsHub)) + if (Servers.Any(server => server.IsHub) || _control.IsConfigured) { await RefreshMeshAsync(showSuccess: false); } @@ -531,6 +668,12 @@ public sealed partial class MainPage : Page private async Task RefreshMeshAsync(bool showSuccess = true) { + if (_control.IsConfigured) + { + await RefreshControlMeshAsync(showSuccess); + return; + } + var hub = FindHub(); if (hub is null) { @@ -596,6 +739,57 @@ public sealed partial class MainPage : Page } } + private async Task RefreshControlMeshAsync(bool showSuccess) + { + try + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + var agents = await _control.GetAgentsAsync(timeout.Token); + var links = await _control.GetLinksAsync(timeout.Token); + var now = DateTimeOffset.UtcNow; + MeshNodes.Clear(); + foreach (var agent in agents) + { + var age = agent.LastSeenAt is null + ? int.MaxValue + : (int)Math.Clamp((now - agent.LastSeenAt.Value).TotalSeconds, 0, int.MaxValue); + MeshNodes.Add(new MeshNodeViewModel( + agent.NodeId, + "Control", + age <= 90 ? "online" : "offline", + age)); + } + + MeshLinks.Clear(); + foreach (var link in links.Where(link => + link.DesiredState != "Disabled" || link.ActualState == "Partial")) + { + MeshLinks.Add(new MeshLinkViewModel( + link.SourceNodeId, + link.TargetNodeId, + link.TargetNodeId, + link.Protocol, + link.Port, + link.ExpiresAt?.ToUnixTimeSeconds() ?? 0, + link.ActualState, + link.Version, + link.Id)); + } + + ActiveLinksValueText.Text = MeshLinks.Count.ToString(CultureInfo.InvariantCulture); + MeshStatusText.Text = $"Control · {MeshNodes.Count} узлов · {MeshLinks.Count} активных связей"; + if (showSuccess) + { + ShowInfo("Control Hub обновлён", MeshStatusText.Text, InfoBarSeverity.Success); + } + } + catch (Exception exception) + { + MeshStatusText.Text = "Control Hub недоступен"; + ShowInfo("Не удалось получить Control-состояние", CompactError(exception), InfoBarSeverity.Error); + } + } + private async void RefreshMeshButton_Click(object sender, RoutedEventArgs e) => await RefreshMeshAsync(); @@ -607,8 +801,9 @@ public sealed partial class MainPage : Page private async Task ChangeLinkAsync(bool enable) { + var useControl = _control.IsConfigured; var hub = FindHub(); - if (hub is null) + if (!useControl && hub is null) { ShowInfo("Mesh Hub не выбран", "Сначала добавьте главный сервер с отметкой Mesh Hub.", InfoBarSeverity.Warning); return; @@ -668,13 +863,49 @@ public sealed partial class MainPage : Page try { + if (useControl) + { + using var controlTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(20)); + LinkPolicy link; + if (enable) + { + link = await _control.CreateLinkAsync( + new LinkPolicyCreateRequest( + source.Name, + target.Name, + protocol, + port, + ttlMinutes, + "Windows client", + Guid.NewGuid().ToString()), + controlTimeout.Token); + } + else + { + var selectedLink = (MeshLinkViewModel)MeshLinksList.SelectedItem; + if (string.IsNullOrWhiteSpace(selectedLink.Id)) + { + throw new InvalidOperationException("У выбранного Link отсутствует Control id."); + } + link = await _control.DisableLinkAsync(selectedLink.Id, controlTimeout.Token); + } + await RefreshMeshAsync(showSuccess: false); + ShowInfo( + enable ? "Control Link создан" : "Control Link отключён", + $"{source.Name} → {target.Name} · {protocol.ToUpperInvariant()}/{port} · {link.ActualState} v{link.Version}", + link.ActualState is "Failed" or "Partial" + ? InfoBarSeverity.Warning + : InfoBarSeverity.Success); + return; + } + var action = enable ? "connect" : "disconnect"; var policyArguments = enable ? $"{protocol} {port} {ttlMinutes}" : $"{protocol} {port}"; using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15)); var commandOutput = await _ssh.RunRestrictedCommandAsync( - hub.Profile, + hub!.Profile, $"mesh {action} {source.Name} {target.Name} {policyArguments}", timeout.Token); var confirmation = commandOutput diff --git a/src/ServerMonitorManager.Desktop/MeshModels.cs b/src/ServerMonitorManager.Desktop/MeshModels.cs index b57352d..31792e8 100644 --- a/src/ServerMonitorManager.Desktop/MeshModels.cs +++ b/src/ServerMonitorManager.Desktop/MeshModels.cs @@ -27,7 +27,8 @@ public sealed class MeshLinkViewModel int port, long expiresUnix, string state, - long version) + long version, + string? id = null) { Source = source; Target = target; @@ -37,6 +38,7 @@ public sealed class MeshLinkViewModel ExpiresUnix = expiresUnix; State = state; Version = version; + Id = id; } public string Source { get; set; } @@ -47,6 +49,7 @@ public sealed class MeshLinkViewModel public long ExpiresUnix { get; set; } public string State { get; set; } public long Version { get; set; } + public string? Id { get; set; } public string ExpirationText => ExpiresUnix == 0 ? "вручную" : $"до {DateTimeOffset.FromUnixTimeSeconds(ExpiresUnix).ToLocalTime():dd.MM HH:mm}"; diff --git a/src/ServerMonitorManager.Desktop/ServerMonitorManager.Desktop.csproj b/src/ServerMonitorManager.Desktop/ServerMonitorManager.Desktop.csproj index d8a10ce..d3e2a0d 100644 --- a/src/ServerMonitorManager.Desktop/ServerMonitorManager.Desktop.csproj +++ b/src/ServerMonitorManager.Desktop/ServerMonitorManager.Desktop.csproj @@ -53,6 +53,7 @@ above to opt out. --> + diff --git a/tests/ServerMonitorManager.Control.Tests/ControlStoreTests.cs b/tests/ServerMonitorManager.Control.Tests/ControlStoreTests.cs index 7201f25..6da2289 100644 --- a/tests/ServerMonitorManager.Control.Tests/ControlStoreTests.cs +++ b/tests/ServerMonitorManager.Control.Tests/ControlStoreTests.cs @@ -62,6 +62,112 @@ public sealed class ControlStoreTests : IAsyncDisposable cancellationToken)); } + [Fact] + public async Task DeviceCertificateReceivesOperatorIdentity() + { + var cancellationToken = TestContext.Current.CancellationToken; + var store = CreateStore(); + await store.InitializeAsync(cancellationToken); + var token = await store.CreateDeviceEnrollmentTokenAsync( + "windows-pc", TimeSpan.FromMinutes(10), cancellationToken); + var issued = new IssuedCertificate("certificate", "ca", "CC33", DateTimeOffset.UtcNow.AddYears(1)); + var request = new DeviceEnrollmentRequest( + "windows-pc", token, "csr", Guid.NewGuid().ToString()); + + var first = await store.EnrollDeviceAsync(request, () => issued, cancellationToken); + var retry = await store.EnrollDeviceAsync( + request, + () => throw new InvalidOperationException("must use cache"), + cancellationToken); + var identity = await store.ResolveIdentityAsync("CC33", cancellationToken); + + Assert.NotNull(first); + Assert.Equal(first, retry); + Assert.Equal(new ControlIdentity("windows-pc", "Operator"), identity); + Assert.False(await store.IsCertificateForNodeAsync("CC33", "home", cancellationToken)); + } + + [Fact] + public async Task LinkDesiredStateIsPersistedBeforeActualStateChanges() + { + var cancellationToken = TestContext.Current.CancellationToken; + var store = CreateStore(); + await store.InitializeAsync(cancellationToken); + await EnrollAgentAsync(store, "ai-agent", "DD44", cancellationToken); + await EnrollAgentAsync(store, "home", "EE55", cancellationToken); + var request = new LinkPolicyCreateRequest( + "ai-agent", "home", "tcp", 22, 120, "development", Guid.NewGuid().ToString()); + + var connectingMutation = await store.CreateLinkMutationAsync(request, "windows-pc", cancellationToken); + var connecting = connectingMutation.Link; + var active = await store.SetLinkActualStateAsync( + connecting.Id, "Active", null, "windows-pc", cancellationToken); + var disconnectingMutation = await store.BeginDisableLinkMutationAsync( + connecting.Id, + new LinkPolicyDisableRequest(Guid.NewGuid().ToString()), + "windows-pc", + cancellationToken); + var disconnecting = disconnectingMutation?.Link; + var disabled = await store.SetLinkActualStateAsync( + connecting.Id, "Disabled", null, "windows-pc", cancellationToken); + var links = await store.ListLinksAsync(cancellationToken); + + Assert.Equal("Active", connecting.DesiredState); + Assert.Equal("Connecting", connecting.ActualState); + Assert.Equal("Active", active?.ActualState); + Assert.Equal("Disabled", disconnecting?.DesiredState); + Assert.Equal("Disconnecting", disconnecting?.ActualState); + Assert.Equal("Disabled", disabled?.ActualState); + Assert.Single(links); + Assert.Equal(disabled, links[0]); + } + + [Fact] + public async Task LinkServiceAppliesPersistedStatesAndPublishesEvents() + { + var cancellationToken = TestContext.Current.CancellationToken; + var store = CreateStore(); + await store.InitializeAsync(cancellationToken); + await EnrollAgentAsync(store, "ai-agent", "FF66", cancellationToken); + await EnrollAgentAsync(store, "home", "0011", cancellationToken); + var broker = new ControlEventBroker(); + using var subscription = broker.Subscribe(); + var applier = new CheckingPolicyApplier(store); + var service = new LinkService(store, applier, broker); + var createRequest = new LinkPolicyCreateRequest( + "ai-agent", "home", "tcp", 22, 30, "test", Guid.NewGuid().ToString()); + + var active = await service.CreateAsync( + createRequest, + "windows-pc", + cancellationToken); + var createReplay = await service.CreateAsync(createRequest, "windows-pc", cancellationToken); + var disableRequest = new LinkPolicyDisableRequest(Guid.NewGuid().ToString()); + var disabled = await service.DisableAsync( + active.Id, + disableRequest, + "windows-pc", + cancellationToken); + var disableReplay = await service.DisableAsync( + active.Id, disableRequest, "windows-pc", cancellationToken); + var eventTypes = new List(); + while (subscription.Reader.TryRead(out var controlEvent)) + { + eventTypes.Add(controlEvent.Type); + } + + Assert.Equal("Active", active.ActualState); + Assert.Equal(active, createReplay); + Assert.Equal("Disabled", disabled?.DesiredState); + Assert.Equal("Disabled", disabled?.ActualState); + Assert.Equal(disabled, disableReplay); + Assert.Equal(1, applier.ConnectCalls); + Assert.Equal(1, applier.DisconnectCalls); + Assert.Equal( + ["link.connecting", "link.active", "link.disconnecting", "link.disabled"], + eventTypes); + } + public ValueTask DisposeAsync() { SqliteConnection.ClearAllPools(); @@ -81,4 +187,44 @@ public sealed class ControlStoreTests : IAsyncDisposable CertificateAuthorityPath = Path.Combine(_directory, "unused.pfx") })); } + + private static async Task EnrollAgentAsync( + ControlStore store, + string nodeId, + string thumbprint, + CancellationToken cancellationToken) + { + var token = await store.CreateEnrollmentTokenAsync(nodeId, TimeSpan.FromMinutes(10), cancellationToken); + var issued = new IssuedCertificate( + "certificate", "ca", thumbprint, DateTimeOffset.UtcNow.AddYears(1)); + var result = await store.EnrollAsync( + new EnrollmentRequest(nodeId, token, "csr", Guid.NewGuid().ToString()), + () => issued, + cancellationToken); + Assert.NotNull(result); + } + + private sealed class CheckingPolicyApplier(ControlStore store) : ILinkPolicyApplier + { + public int ConnectCalls { get; private set; } + public int DisconnectCalls { get; private set; } + + public async Task ApplyConnectAsync(LinkPolicy link, CancellationToken cancellationToken) + { + ConnectCalls++; + var persisted = Assert.Single(await store.ListLinksAsync(cancellationToken)); + Assert.Equal(link.Id, persisted.Id); + Assert.Equal("Active", persisted.DesiredState); + Assert.Equal("Connecting", persisted.ActualState); + } + + public async Task ApplyDisconnectAsync(LinkPolicy link, CancellationToken cancellationToken) + { + DisconnectCalls++; + var persisted = Assert.Single(await store.ListLinksAsync(cancellationToken)); + Assert.Equal(link.Id, persisted.Id); + Assert.Equal("Disabled", persisted.DesiredState); + Assert.Equal("Disconnecting", persisted.ActualState); + } + } }