From d63554c6292e5afd8bd44234def9e15af77dfb9f Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Thu, 16 Jul 2026 23:59:52 +0700 Subject: [PATCH] Add certificate re-enrollment lifecycle --- README.md | 6 +- docs/i18n/README.ar.md | 2 +- docs/i18n/README.de.md | 2 +- docs/i18n/README.es.md | 2 +- docs/i18n/README.fr.md | 2 +- docs/i18n/README.hi.md | 2 +- docs/i18n/README.ja.md | 2 +- docs/i18n/README.ko.md | 2 +- docs/i18n/README.pt-BR.md | 2 +- docs/i18n/README.ru.md | 2 +- docs/i18n/README.tr.md | 2 +- docs/i18n/README.zh-CN.md | 2 +- docs/roadmap.md | 2 +- docs/security-model.md | 2 + .../CertificateLifecycleService.cs | 97 ++++++++ .../ControlStore.cs | 227 ++++++++++++++++++ src/ServerMonitorManager.Control/Program.cs | 67 ++++++ src/ServerMonitorManager.Core/Contracts.cs | 17 ++ .../SmmJsonContext.cs | 3 + .../ControlClientService.cs | 18 ++ .../MainPage.xaml | 34 ++- .../MainPage.xaml.cs | 90 ++++++- .../ControlStoreTests.cs | 85 +++++++ 23 files changed, 647 insertions(+), 23 deletions(-) create mode 100644 src/ServerMonitorManager.Control/CertificateLifecycleService.cs diff --git a/README.md b/README.md index 5e30120..3c9b333 100644 --- a/README.md +++ b/README.md @@ -116,11 +116,11 @@ In the application, generate or copy the monitoring SSH key, add the Hub profile ## Current status -`v0.1.0-alpha.2` is an early testing release, not a production security appliance. Windows and Linux builds, six control-plane tests, Bash syntax checks, self-contained `linux-x64`/`linux-arm64` artifacts, and checksums are automated in GitHub Actions. +`v0.1.0-alpha.3` is an early testing release, not a production security appliance. Windows and Linux builds, control-plane tests, Bash syntax checks, self-contained `linux-x64`/`linux-arm64` artifacts, and checksums are automated in GitHub Actions. -The current development branch implements Windows SSH monitoring, the Hub/Node WireGuard installer, directional Links, one-time enrollment, mTLS Agent and Operator identities, SQLite control state, audit, authenticated event streaming, Windows Control API integration, and a bounded durable Agent buffer with downsampling. +The current development branch implements Windows SSH monitoring, the Hub/Node WireGuard installer, directional Links, one-time enrollment, mTLS Agent and Operator identities, certificate revocation/re-enrollment, SQLite control state, audit, authenticated event streaming, Windows Control API integration, and a bounded durable Agent buffer with downsampling. -Still planned: certificate revoke/re-enroll, reconnect reconciliation, kill-switch failure tests, a 50–100 Node load test, signed Windows installer, and desktop/mobile clients for additional platforms. +Still planned: reconnect reconciliation, kill-switch failure tests, a 50–100 Node load test, signed Windows installer, and desktop/mobile clients for additional platforms. ## License and project policy diff --git a/docs/i18n/README.ar.md b/docs/i18n/README.ar.md index 2384894..b8163fc 100644 --- a/docs/i18n/README.ar.md +++ b/docs/i18n/README.ar.md @@ -32,7 +32,7 @@ sudo ./ochenstarik-server-monitor-manager.sh hub لا توجد كلمة مرور root مشتركة ولا يغادر مفتاح WireGuard الخاص عقدته. هويات monitoring وterminal وAgent وOperator وأتمتة AI منفصلة. يستخدم SSH أمراً إجبارياً بلا shell أو PTY أو forwarding؛ يقيّد mTLS الصلاحيات؛ تسمح nftables بالروابط الصريحة فقط؛ ويحفظ SQLite الحالة المطلوبة والتدقيق قبل تعديل الجدار الناري. -الإصدار `v0.1.0-alpha.2` للاختبار. يتضمن فرع التطوير الحالي عميل Windows ومثبت Hub/Node وLinks وmTLS وSQLite والتدقيق والأحداث ومخزناً محدوداً دون اتصال مع downsampling. المتبقي: إلغاء الشهادات، مصالحة إعادة الاتصال، اختبار 50–100 Node ومثبت Windows موقّع. +الإصدار `v0.1.0-alpha.3` للاختبار. يتضمن فرع التطوير الحالي عميل Windows ومثبت Hub/Node وLinks وmTLS وإلغاء الشهادات وإعادة التسجيل وSQLite والتدقيق والأحداث ومخزناً محدوداً دون اتصال مع downsampling. المتبقي: مصالحة إعادة الاتصال، اختبار 50–100 Node ومثبت Windows موقّع. ## الترخيص diff --git a/docs/i18n/README.de.md b/docs/i18n/README.de.md index d1e0627..dd9bbe0 100644 --- a/docs/i18n/README.de.md +++ b/docs/i18n/README.de.md @@ -32,7 +32,7 @@ sudo ./ochenstarik-server-monitor-manager.sh hub Es gibt kein gemeinsames Root-Passwort; der private WireGuard-Schlüssel bleibt auf dem Node. Monitoring-, Terminal-, Agent-, Operator- und KI-Automationsidentitäten sind getrennt. SSH nutzt einen forced-command ohne Shell/PTY/Forwarding; mTLS begrenzt Rollen; nftables erlaubt nur explizite Links; SQLite speichert Sollzustand und Audit vor der Firewalländerung. -`v0.1.0-alpha.2` ist eine Testversion. Der aktuelle Entwicklungszweig enthält bereits Windows-Client, Hub/Node-Installer, Links, mTLS, SQLite, Audit, Events und einen begrenzten Offline-Puffer mit Downsampling. Offen sind Zertifikatswiderruf, Reconnect-Abgleich, Lasttest mit 50–100 Nodes und signierter Windows-Installer. +`v0.1.0-alpha.3` ist eine Testversion. Der aktuelle Entwicklungszweig enthält Windows-Client, Hub/Node-Installer, Links, mTLS, Zertifikatswiderruf und erneute Registrierung, SQLite, Audit, Events und einen begrenzten Offline-Puffer mit Downsampling. Offen sind Reconnect-Abgleich, Lasttest mit 50–100 Nodes und signierter Windows-Installer. ## Lizenz diff --git a/docs/i18n/README.es.md b/docs/i18n/README.es.md index c52af5d..bac0c76 100644 --- a/docs/i18n/README.es.md +++ b/docs/i18n/README.es.md @@ -34,7 +34,7 @@ Abra UDP `51820` y TCP `7443` en el Hub, cree códigos para los Nodes e instále No hay contraseña root compartida ni claves WireGuard privadas de Nodes en el Hub. Las identidades de monitorización, terminal, Agent, Operator y automatización IA están separadas. SSH usa un forced-command sin shell, PTY ni forwarding; mTLS limita cada rol; nftables permite únicamente Links explícitos; SQLite conserva estado y auditoría antes de aplicar cambios. -`v0.1.0-alpha.2` es una versión de prueba. La rama de desarrollo actual ya incluye cliente Windows, instalador Hub/Node, Links, mTLS, SQLite, auditoría, eventos y un búfer offline limitado con downsampling. Faltan la revocación de certificados, reconciliación tras reconexión, prueba de 50–100 Nodes e instalador Windows firmado. +`v0.1.0-alpha.3` es una versión de prueba. La rama actual incluye cliente Windows, instalador Hub/Node, Links, mTLS, revocación y reinscripción de certificados, SQLite, auditoría, eventos y un búfer offline limitado con downsampling. Faltan la reconciliación tras reconexión, prueba de 50–100 Nodes e instalador Windows firmado. ## Licencia diff --git a/docs/i18n/README.fr.md b/docs/i18n/README.fr.md index 1755919..9f9022c 100644 --- a/docs/i18n/README.fr.md +++ b/docs/i18n/README.fr.md @@ -32,7 +32,7 @@ Ouvrez UDP `51820` et TCP `7443` sur le Hub, créez les codes et installez les a Aucun mot de passe root n'est partagé et la clé WireGuard privée reste sur le Node. Les identités monitoring, terminal, Agent, Operator et automatisation IA sont séparées. SSH emploie une forced-command sans shell/PTY/forwarding ; mTLS limite les rôles ; nftables n'autorise que les Links explicites ; SQLite enregistre état et audit avant le changement de pare-feu. -`v0.1.0-alpha.2` est destiné aux tests. La branche de développement actuelle contient déjà le client Windows, l'installateur Hub/Node, les Links, mTLS, SQLite, l'audit, les événements et un tampon hors ligne limité avec downsampling. Restent la révocation des certificats, la réconciliation, le test de 50–100 Nodes et l'installateur Windows signé. +`v0.1.0-alpha.3` est destiné aux tests. La branche actuelle contient le client Windows, l'installateur Hub/Node, les Links, mTLS, la révocation et le ré-enrôlement des certificats, SQLite, l'audit, les événements et un tampon hors ligne limité avec downsampling. Restent la réconciliation, le test de 50–100 Nodes et l'installateur Windows signé. ## Licence diff --git a/docs/i18n/README.hi.md b/docs/i18n/README.hi.md index e3ae448..51821f1 100644 --- a/docs/i18n/README.hi.md +++ b/docs/i18n/README.hi.md @@ -32,7 +32,7 @@ Hub पर UDP `51820` और TCP `7443` खोलें, Node codes बना Shared root password नहीं है और Node की WireGuard private key Node से बाहर नहीं जाती। Monitoring, terminal, Agent, Operator और AI automation identities अलग हैं। SSH forced-command shell/PTY/forwarding नहीं देता; mTLS roles सीमित करता है; nftables केवल explicit Links स्वीकारता है; SQLite पहले desired state और audit सहेजता है। -`v0.1.0-alpha.2` testing release है। Current development branch में Windows client, Hub/Node installer, Links, mTLS, SQLite, audit, event stream और downsampling वाला सीमित offline buffer तैयार हैं। Certificate revocation, reconnect reconciliation, 50–100 Node load test और signed Windows installer अभी बाकी हैं। +`v0.1.0-alpha.3` testing release है। Current development branch में Windows client, Hub/Node installer, Links, mTLS, certificate revocation और re-enrollment, SQLite, audit, event stream तथा downsampling वाला सीमित offline buffer तैयार हैं। Reconnect reconciliation, 50–100 Node load test और signed Windows installer अभी बाकी हैं। ## लाइसेंस diff --git a/docs/i18n/README.ja.md b/docs/i18n/README.ja.md index 585b3d0..c69a408 100644 --- a/docs/i18n/README.ja.md +++ b/docs/i18n/README.ja.md @@ -32,7 +32,7 @@ Hub で UDP `51820` と TCP `7443` を開き、Node コードを作成して他 共有 root パスワードはなく、Node の WireGuard 秘密鍵は Node 外に出ません。monitoring、terminal、Agent、Operator、AI automation の ID は分離されています。SSH は shell/PTY/forwarding のない forced-command、mTLS はロール制限、nftables は明示 Link のみを許可し、SQLite は firewall 変更前に状態と監査を保存します。 -`v0.1.0-alpha.2` はテスト版です。現在の開発ブランチには Windows client、Hub/Node installer、Links、mTLS、SQLite、監査、イベント、downsampling 付きの制限オフラインバッファが実装済みです。証明書失効、再接続調整、50–100 Node 負荷試験、署名付き Windows installer は今後の課題です。 +`v0.1.0-alpha.3` はテスト版です。現在の開発ブランチには Windows client、Hub/Node installer、Links、mTLS、証明書失効と再登録、SQLite、監査、イベント、downsampling 付きの制限オフラインバッファが実装済みです。再接続調整、50–100 Node 負荷試験、署名付き Windows installer は今後の課題です。 ## ライセンス diff --git a/docs/i18n/README.ko.md b/docs/i18n/README.ko.md index 9f135f6..8cd8a9a 100644 --- a/docs/i18n/README.ko.md +++ b/docs/i18n/README.ko.md @@ -32,7 +32,7 @@ Hub에서 UDP `51820`과 TCP `7443`을 열고 Node 코드를 생성한 뒤 다 공유 root 암호가 없고 Node WireGuard 개인 키는 Node를 떠나지 않습니다. monitoring, terminal, Agent, Operator, AI automation identity는 분리됩니다. SSH는 shell/PTY/forwarding 없는 forced-command를 사용하고, mTLS는 역할을 제한하며, nftables는 명시된 Link만 허용합니다. SQLite는 방화벽 변경 전에 상태와 감사를 저장합니다. -`v0.1.0-alpha.2`는 테스트 릴리스입니다. 현재 개발 branch에는 Windows client, Hub/Node installer, Links, mTLS, SQLite, 감사, event stream과 downsampling이 적용된 제한 offline buffer가 구현되었습니다. 인증서 폐기, 재연결 조정, 50–100 Node 부하 시험과 서명된 Windows installer가 남아 있습니다. +`v0.1.0-alpha.3`는 테스트 릴리스입니다. 현재 개발 branch에는 Windows client, Hub/Node installer, Links, mTLS, 인증서 폐기와 재등록, SQLite, 감사, event stream과 downsampling이 적용된 제한 offline buffer가 구현되었습니다. 재연결 조정, 50–100 Node 부하 시험과 서명된 Windows installer가 남아 있습니다. ## 라이선스 diff --git a/docs/i18n/README.pt-BR.md b/docs/i18n/README.pt-BR.md index 3e1cdf7..1b63839 100644 --- a/docs/i18n/README.pt-BR.md +++ b/docs/i18n/README.pt-BR.md @@ -32,7 +32,7 @@ Abra UDP `51820` e TCP `7443` no Hub, gere códigos e instale os demais servidor Não há senha root compartilhada e a chave WireGuard privada nunca sai do Node. As identidades de monitoramento, terminal, Agent, Operator e automação de IA são separadas. SSH usa forced-command sem shell/PTY/forwarding; mTLS restringe funções; nftables permite apenas Links explícitos; SQLite registra estado e auditoria antes da mudança no firewall. -`v0.1.0-alpha.2` é uma versão de teste. A branch de desenvolvimento atual já contém cliente Windows, instalador Hub/Node, Links, mTLS, SQLite, auditoria, eventos e buffer offline limitado com downsampling. Restam revogação de certificado, reconciliação, teste com 50–100 Nodes e instalador Windows assinado. +`v0.1.0-alpha.3` é uma versão de teste. A branch atual contém cliente Windows, instalador Hub/Node, Links, mTLS, revogação e novo registro de certificados, SQLite, auditoria, eventos e buffer offline limitado com downsampling. Restam reconciliação, teste com 50–100 Nodes e instalador Windows assinado. ## Licença diff --git a/docs/i18n/README.ru.md b/docs/i18n/README.ru.md index f942896..5744b67 100644 --- a/docs/i18n/README.ru.md +++ b/docs/i18n/README.ru.md @@ -38,7 +38,7 @@ sudo ./ochenstarik-server-monitor-manager.sh hub Нет общих root-паролей; приватный WireGuard-ключ Node не покидает Node. Идентичности monitoring, terminal, Agent, Operator и AI-автоматизации разделены. Monitoring SSH использует forced-command без shell/PTY/forwarding. Agent может отправлять heartbeat только своего узла, Operator управляет inventory и Links. Отключение Link сначала сохраняется в SQLite, затем удаляет разрешение nftables; повтор запроса не повторяет побочный эффект. -`v0.1.0-alpha.2` предназначен для тестирования. В текущей ветке разработки уже готовы Windows SSH-monitoring, Hub/Node installer, Links, mTLS, SQLite, аудит, поток событий и ограниченный offline-буфер Agent с downsampling. Остались отзыв сертификатов, reconnect reconciliation, нагрузочный тест 50–100 Node и подписанный Windows installer. До стабильного релиза используйте тестовые или резервируемые серверы. +`v0.1.0-alpha.3` предназначен для тестирования. В текущей ветке разработки уже готовы Windows SSH-monitoring, Hub/Node installer, Links, mTLS, отзыв и повторная регистрация сертификатов, SQLite, аудит, поток событий и ограниченный offline-буфер Agent с downsampling. Остались reconnect reconciliation, нагрузочный тест 50–100 Node и подписанный Windows installer. До стабильного релиза используйте тестовые или резервируемые серверы. ## Лицензия diff --git a/docs/i18n/README.tr.md b/docs/i18n/README.tr.md index 0a66c0c..84efad3 100644 --- a/docs/i18n/README.tr.md +++ b/docs/i18n/README.tr.md @@ -32,7 +32,7 @@ Hub üzerinde UDP `51820` ve TCP `7443` açın, Node kodlarını üretin ve diğ Ortak root parolası yoktur ve Node'un WireGuard özel anahtarı Node'dan çıkmaz. Monitoring, terminal, Agent, Operator ve AI automation kimlikleri ayrıdır. SSH shell/PTY/forwarding vermeyen forced-command kullanır; mTLS rolleri sınırlar; nftables yalnızca açık Links'e izin verir; SQLite güvenlik duvarı değişmeden önce durum ve audit kaydeder. -`v0.1.0-alpha.2` test sürümüdür. Güncel geliştirme dalında Windows client, Hub/Node installer, Links, mTLS, SQLite, audit, event stream ve downsampling kullanan sınırlı offline buffer hazırdır. Sertifika iptali, yeniden bağlantı uzlaştırması, 50–100 Node yük testi ve imzalı Windows installer sıradadır. +`v0.1.0-alpha.3` test sürümüdür. Güncel geliştirme dalında Windows client, Hub/Node installer, Links, mTLS, sertifika iptali ve yeniden kayıt, SQLite, audit, event stream ve downsampling kullanan sınırlı offline buffer hazırdır. Yeniden bağlantı uzlaştırması, 50–100 Node yük testi ve imzalı Windows installer sıradadır. ## Lisans diff --git a/docs/i18n/README.zh-CN.md b/docs/i18n/README.zh-CN.md index 95848ee..3d52eb8 100644 --- a/docs/i18n/README.zh-CN.md +++ b/docs/i18n/README.zh-CN.md @@ -34,7 +34,7 @@ sudo ./ochenstarik-server-monitor-manager.sh hub 系统不共享 root 密码,Node 的 WireGuard 私钥不会离开本机。监控、终端、Agent、Operator 和 AI 自动化身份相互隔离。SSH 使用无 shell、PTY、转发权限的 forced-command;mTLS 限制角色;nftables 仅允许明确 Link;SQLite 在执行防火墙变更前保存目标状态和审计。 -`v0.1.0-alpha.2` 是测试版。当前开发分支已包含 Windows 客户端、Hub/Node 安装器、Links、mTLS、SQLite、审计、事件流以及带降采样的有限离线缓冲。待完成:证书撤销、重连协调、50–100 Node 压测和签名 Windows 安装器。 +`v0.1.0-alpha.3` 是测试版。当前开发分支已包含 Windows 客户端、Hub/Node 安装器、Links、mTLS、证书撤销与重新注册、SQLite、审计、事件流以及带降采样的有限离线缓冲。待完成:重连协调、50–100 Node 压测和签名 Windows 安装器。 ## 许可证 diff --git a/docs/roadmap.md b/docs/roadmap.md index 354a928..40e1532 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -40,7 +40,7 @@ - [x] одноразовый enrollment token; - [x] срок действия не более 10 минут; - [x] атомарное погашение token; -- [ ] отзыв и повторная регистрация Node; +- [x] отзыв и повторная регистрация Node; - [x] подтверждение SHA-256 fingerprint Control CA Hub; - [x] защита desktop SSH-ключа через DPAPI. diff --git a/docs/security-model.md b/docs/security-model.md index d050594..5ff358a 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -35,6 +35,8 @@ Windows-клиент получает отдельный код `SMMDEV1`, по Сертификат `Agent` разрешает только heartbeat собственного `node_id`. Только сертификат с ролью `Operator` может читать общий inventory, изменять Links и подписываться на event stream. Control service вызывает от root только отдельный wrapper с командами `link-connect` и `link-disconnect`; другие Hub-команды через него запрещены. +При перерегистрации Node Control Hub в одной SQLite-транзакции помечает старый сертификат `Revoked`, погашает ранее выданные enrollment tokens и переводит все связанные Links в желаемое состояние `Disabled`. Только после этого ограниченный firewall wrapper удаляет фактические правила. Новый token живёт 10 минут, а повтор запроса с тем же idempotency key не создаёт второй token и не повторяет firewall-операции. Windows-клиент требует отдельного подтверждения и не публикует token в event stream или аудит. + ## Авторизация Links - мониторинг read-only отделён от управления; diff --git a/src/ServerMonitorManager.Control/CertificateLifecycleService.cs b/src/ServerMonitorManager.Control/CertificateLifecycleService.cs new file mode 100644 index 0000000..770f37b --- /dev/null +++ b/src/ServerMonitorManager.Control/CertificateLifecycleService.cs @@ -0,0 +1,97 @@ +using System.Text.Json; +using ServerMonitorManager.Core; + +namespace ServerMonitorManager.Control; + +public sealed class CertificateLifecycleService( + ControlStore store, + ILinkPolicyApplier applier, + ControlEventBroker events) +{ + private static readonly TimeSpan TicketLifetime = TimeSpan.FromMinutes(10); + + public async Task ReenrollAgentAsync( + string nodeId, + CertificateReenrollmentRequest request, + string actor, + CancellationToken cancellationToken) + { + var mutation = await store.BeginAgentReenrollmentAsync( + nodeId, request, actor, TicketLifetime, cancellationToken); + if (mutation is null) + { + return null; + } + + if (mutation.IsReplay) + { + return mutation.Ticket; + } + + PublishCertificate("agent.revoked", mutation.Ticket); + foreach (var pendingLink in mutation.Links) + { + PublishLink("link.disconnecting", pendingLink); + LinkPolicy actual; + try + { + await applier.ApplyDisconnectAsync(pendingLink, cancellationToken); + actual = await store.SetLinkActualStateAsync( + pendingLink.Id, "Disabled", null, actor, cancellationToken) + ?? pendingLink; + PublishLink("link.disabled", actual); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + actual = await store.SetLinkActualStateAsync( + pendingLink.Id, + "Partial", + CompactError(exception), + actor, + cancellationToken) + ?? pendingLink; + PublishLink("link.partial", actual); + } + } + + return mutation.Ticket; + } + + public async Task 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)); + + private void PublishLink(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/ControlStore.cs b/src/ServerMonitorManager.Control/ControlStore.cs index 8990b14..349cb47 100644 --- a/src/ServerMonitorManager.Control/ControlStore.cs +++ b/src/ServerMonitorManager.Control/ControlStore.cs @@ -425,6 +425,197 @@ public sealed class ControlStore(IOptions options) return result; } + public async Task BeginAgentReenrollmentAsync( + string nodeId, + CertificateReenrollmentRequest request, + string actor, + TimeSpan lifetime, + CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken); + await using var transaction = (SqliteTransaction)await connection.BeginTransactionAsync(cancellationToken); + var operationKey = $"agent-reenroll:{actor}:{nodeId}:{request.IdempotencyKey}"; + var requestHash = Fingerprint(request, SmmJsonContext.Default.CertificateReenrollmentRequest); + var cached = await ReadIdempotentAsync( + connection, + transaction, + operationKey, + requestHash, + SmmJsonContext.Default.CertificateReenrollmentTicket, + cancellationToken); + if (cached is not null) + { + await transaction.CommitAsync(cancellationToken); + return new AgentReenrollmentMutation(cached, [], true); + } + + var exists = connection.CreateCommand(); + exists.Transaction = transaction; + exists.CommandText = "SELECT EXISTS(SELECT 1 FROM agents WHERE node_id = $id);"; + exists.Parameters.AddWithValue("$id", nodeId); + if (Convert.ToInt32(await exists.ExecuteScalarAsync(cancellationToken)) != 1) + { + await transaction.RollbackAsync(cancellationToken); + return null; + } + + var now = DateTimeOffset.UtcNow; + var revoke = connection.CreateCommand(); + revoke.Transaction = transaction; + revoke.CommandText = "UPDATE agents SET status = 'Revoked' WHERE node_id = $id;"; + revoke.Parameters.AddWithValue("$id", nodeId); + await revoke.ExecuteNonQueryAsync(cancellationToken); + + var pendingLinks = new List(); + var links = connection.CreateCommand(); + links.Transaction = transaction; + links.CommandText = """ + SELECT * FROM links + WHERE (source_node_id = $id OR target_node_id = $id) + AND desired_state != 'Disabled' + ORDER BY created_at; + """; + links.Parameters.AddWithValue("$id", nodeId); + await using (var reader = await links.ExecuteReaderAsync(cancellationToken)) + { + while (await reader.ReadAsync(cancellationToken)) + { + pendingLinks.Add(ReadLink(reader) with + { + DesiredState = "Disabled", + ActualState = "Disconnecting", + Version = reader.GetInt64(9) + 1, + UpdatedAt = now, + LastError = null + }); + } + } + + foreach (var link in pendingLinks) + { + var update = connection.CreateCommand(); + update.Transaction = transaction; + update.CommandText = """ + UPDATE links SET + desired_state = 'Disabled', + actual_state = 'Disconnecting', + version = $version, + updated_at = $updated, + last_error = NULL + WHERE id = $id; + """; + update.Parameters.AddWithValue("$version", link.Version); + update.Parameters.AddWithValue("$updated", link.UpdatedAt.ToString("O")); + update.Parameters.AddWithValue("$id", link.Id); + await update.ExecuteNonQueryAsync(cancellationToken); + } + + var token = CreateSecureToken(); + var expiresAt = now.Add(lifetime); + await ReplaceEnrollmentTokenAsync( + connection, + transaction, + "enrollment_tokens", + "node_id", + nodeId, + token, + now, + expiresAt, + cancellationToken); + var ticket = new CertificateReenrollmentTicket( + "Agent", nodeId, token, expiresAt, pendingLinks.Count); + await WriteIdempotentAsync( + connection, + transaction, + operationKey, + requestHash, + ticket, + SmmJsonContext.Default.CertificateReenrollmentTicket, + cancellationToken); + await WriteAuditAsync( + connection, + transaction, + actor, + "agent.reenrollment.requested", + nodeId, + JsonSerializer.Serialize( + new CertificateStatusEvent("Agent", nodeId, "Revoked", pendingLinks.Count), + SmmJsonContext.Default.CertificateStatusEvent), + cancellationToken); + await transaction.CommitAsync(cancellationToken); + return new AgentReenrollmentMutation(ticket, pendingLinks, false); + } + + public async Task BeginDeviceReenrollmentAsync( + string deviceId, + CertificateReenrollmentRequest request, + string actor, + TimeSpan lifetime, + CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken); + await using var transaction = (SqliteTransaction)await connection.BeginTransactionAsync(cancellationToken); + var operationKey = $"device-reenroll:{actor}:{deviceId}:{request.IdempotencyKey}"; + var requestHash = Fingerprint(request, SmmJsonContext.Default.CertificateReenrollmentRequest); + var cached = await ReadIdempotentAsync( + connection, + transaction, + operationKey, + requestHash, + SmmJsonContext.Default.CertificateReenrollmentTicket, + cancellationToken); + if (cached is not null) + { + await transaction.CommitAsync(cancellationToken); + return cached; + } + + var revoke = connection.CreateCommand(); + revoke.Transaction = transaction; + revoke.CommandText = "UPDATE devices SET status = 'Revoked' WHERE device_id = $id;"; + revoke.Parameters.AddWithValue("$id", deviceId); + if (await revoke.ExecuteNonQueryAsync(cancellationToken) != 1) + { + await transaction.RollbackAsync(cancellationToken); + return null; + } + + var now = DateTimeOffset.UtcNow; + var token = CreateSecureToken(); + var expiresAt = now.Add(lifetime); + await ReplaceEnrollmentTokenAsync( + connection, + transaction, + "device_tokens", + "device_id", + deviceId, + token, + now, + expiresAt, + cancellationToken); + var ticket = new CertificateReenrollmentTicket("Operator", deviceId, token, expiresAt, 0); + await WriteIdempotentAsync( + connection, + transaction, + operationKey, + requestHash, + ticket, + SmmJsonContext.Default.CertificateReenrollmentTicket, + cancellationToken); + await WriteAuditAsync( + connection, + transaction, + actor, + "device.reenrollment.requested", + deviceId, + JsonSerializer.Serialize( + new CertificateStatusEvent("Operator", deviceId, "Revoked", 0), + SmmJsonContext.Default.CertificateStatusEvent), + cancellationToken); + await transaction.CommitAsync(cancellationToken); + return ticket; + } + public async Task CreateLinkMutationAsync( LinkPolicyCreateRequest request, string actor, @@ -688,6 +879,37 @@ public sealed class ControlStore(IOptions options) return connection; } + private static async Task ReplaceEnrollmentTokenAsync( + SqliteConnection connection, + SqliteTransaction transaction, + string table, + string idColumn, + string id, + string token, + DateTimeOffset now, + DateTimeOffset expiresAt, + CancellationToken cancellationToken) + { + var consume = connection.CreateCommand(); + consume.Transaction = transaction; + consume.CommandText = $"UPDATE {table} SET consumed_at = $now WHERE {idColumn} = $id AND consumed_at IS NULL;"; + consume.Parameters.AddWithValue("$now", now.ToString("O")); + consume.Parameters.AddWithValue("$id", id); + await consume.ExecuteNonQueryAsync(cancellationToken); + + var insert = connection.CreateCommand(); + insert.Transaction = transaction; + insert.CommandText = $"INSERT INTO {table}(token_hash, {idColumn}, expires_at) VALUES ($hash, $id, $expires);"; + insert.Parameters.AddWithValue("$hash", Hash(token)); + insert.Parameters.AddWithValue("$id", id); + insert.Parameters.AddWithValue("$expires", expiresAt.ToString("O")); + await insert.ExecuteNonQueryAsync(cancellationToken); + } + + private static string CreateSecureToken() + => Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)) + .TrimEnd('=').Replace('+', '-').Replace('/', '_'); + private static string Hash(string value) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))); @@ -775,3 +997,8 @@ public sealed class IdempotencyConflictException : Exception public sealed record ControlIdentity(string Id, string Role); public sealed record LinkMutation(LinkPolicy Link, bool IsReplay); + +public sealed record AgentReenrollmentMutation( + CertificateReenrollmentTicket Ticket, + IReadOnlyList Links, + bool IsReplay); diff --git a/src/ServerMonitorManager.Control/Program.cs b/src/ServerMonitorManager.Control/Program.cs index df08968..7487cc1 100644 --- a/src/ServerMonitorManager.Control/Program.cs +++ b/src/ServerMonitorManager.Control/Program.cs @@ -43,6 +43,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddAuthentication(CertificateAuthenticationDefaults.AuthenticationScheme) .AddCertificate(options => { @@ -274,6 +275,65 @@ 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.MapPost("/agents/{nodeId}/reenroll", async ( + string nodeId, + CertificateReenrollmentRequest request, + HttpContext context, + CertificateLifecycleService lifecycle, + CancellationToken cancellationToken) => +{ + if (!NodeIdValidator.IsValid(nodeId) || !CertificateReenrollmentValidator.IsValid(request)) + { + return Results.ValidationProblem(new Dictionary + { + ["certificate"] = ["Invalid node id, reason, or idempotency key."] + }); + } + + try + { + var actor = context.User.FindFirstValue(ClaimTypes.NameIdentifier)!; + var ticket = await lifecycle.ReenrollAgentAsync(nodeId, request, actor, cancellationToken); + return ticket is null ? Results.NotFound() : Results.Ok(ticket); + } + catch (IdempotencyConflictException) + { + return Results.Conflict(new ProblemDetails { Title = "Idempotency key conflict" }); + } +}); +control.MapPost("/devices/{deviceId}/reenroll", async ( + string deviceId, + CertificateReenrollmentRequest request, + HttpContext context, + CertificateLifecycleService lifecycle, + CancellationToken cancellationToken) => +{ + if (!NodeIdValidator.IsValid(deviceId) || !CertificateReenrollmentValidator.IsValid(request)) + { + return Results.ValidationProblem(new Dictionary + { + ["certificate"] = ["Invalid device id, reason, or idempotency key."] + }); + } + + try + { + var actor = context.User.FindFirstValue(ClaimTypes.NameIdentifier)!; + if (string.Equals(actor, deviceId, StringComparison.Ordinal)) + { + return Results.BadRequest(new ProblemDetails + { + Title = "An Operator cannot revoke its own certificate. Use another Operator or the local Hub CLI." + }); + } + var ticket = await lifecycle.ReenrollDeviceAsync(deviceId, request, actor, cancellationToken); + return ticket is null ? Results.NotFound() : Results.Ok(ticket); + } + catch (IdempotencyConflictException) + { + return Results.Conflict(new ProblemDetails { Title = "Idempotency key conflict" }); + } +}); control.MapGet("/links", async (ControlStore controlStore, CancellationToken cancellationToken) => Results.Ok((await controlStore.ListLinksAsync(cancellationToken)).ToArray())); control.MapPost("/links", async ( @@ -381,3 +441,10 @@ internal static class LinkPolicyValidator && request.Reason.Length <= 256 && IdempotencyKeyValidator.IsValid(request.IdempotencyKey); } + +internal static class CertificateReenrollmentValidator +{ + public static bool IsValid(CertificateReenrollmentRequest request) + => request.Reason.Length is >= 1 and <= 200 + && IdempotencyKeyValidator.IsValid(request.IdempotencyKey); +} diff --git a/src/ServerMonitorManager.Core/Contracts.cs b/src/ServerMonitorManager.Core/Contracts.cs index 59aa326..0e65154 100644 --- a/src/ServerMonitorManager.Core/Contracts.cs +++ b/src/ServerMonitorManager.Core/Contracts.cs @@ -38,6 +38,23 @@ public sealed record AgentSummary( string AgentVersion, DateTimeOffset? LastSeenAt); +public sealed record CertificateReenrollmentRequest( + string Reason, + string IdempotencyKey); + +public sealed record CertificateReenrollmentTicket( + string EntityType, + string EntityId, + string Token, + DateTimeOffset ExpiresAt, + int DisabledLinks); + +public sealed record CertificateStatusEvent( + string EntityType, + string EntityId, + string Status, + int DisabledLinks); + public sealed record DeviceEnrollmentRequest( string DeviceId, string Token, diff --git a/src/ServerMonitorManager.Core/SmmJsonContext.cs b/src/ServerMonitorManager.Core/SmmJsonContext.cs index 58db693..88a1560 100644 --- a/src/ServerMonitorManager.Core/SmmJsonContext.cs +++ b/src/ServerMonitorManager.Core/SmmJsonContext.cs @@ -8,6 +8,9 @@ namespace ServerMonitorManager.Core; [JsonSerializable(typeof(AgentHeartbeat[]))] [JsonSerializable(typeof(AgentHeartbeatResponse))] [JsonSerializable(typeof(AgentSummary[]))] +[JsonSerializable(typeof(CertificateReenrollmentRequest))] +[JsonSerializable(typeof(CertificateReenrollmentTicket))] +[JsonSerializable(typeof(CertificateStatusEvent))] [JsonSerializable(typeof(DeviceEnrollmentRequest))] [JsonSerializable(typeof(DeviceEnrollmentResponse))] [JsonSerializable(typeof(LinkPolicyCreateRequest))] diff --git a/src/ServerMonitorManager.Desktop/ControlClientService.cs b/src/ServerMonitorManager.Desktop/ControlClientService.cs index eff3be0..d6b4c2a 100644 --- a/src/ServerMonitorManager.Desktop/ControlClientService.cs +++ b/src/ServerMonitorManager.Desktop/ControlClientService.cs @@ -233,6 +233,24 @@ public sealed partial class ControlClientService cancellationToken) ?? throw new InvalidOperationException("Control Hub вернул пустой Link."); } + public async Task ReenrollAgentAsync( + string nodeId, + string reason, + CancellationToken cancellationToken) + { + using var session = await RequireAuthenticatedSessionAsync(cancellationToken); + using var response = await session.Client.PostAsJsonAsync( + $"api/v1/control/agents/{Uri.EscapeDataString(nodeId)}/reenroll", + new CertificateReenrollmentRequest(reason, Guid.NewGuid().ToString()), + SmmJsonContext.Default.CertificateReenrollmentRequest, + cancellationToken); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadFromJsonAsync( + SmmJsonContext.Default.CertificateReenrollmentTicket, + cancellationToken) + ?? throw new InvalidOperationException("Control Hub вернул пустой token перерегистрации."); + } + private static HttpClient CreateHttpClient( Uri baseAddress, byte[] rootBytes, diff --git a/src/ServerMonitorManager.Desktop/MainPage.xaml b/src/ServerMonitorManager.Desktop/MainPage.xaml index ab1da42..c84382d 100644 --- a/src/ServerMonitorManager.Desktop/MainPage.xaml +++ b/src/ServerMonitorManager.Desktop/MainPage.xaml @@ -281,7 +281,11 @@ BorderThickness="1" CornerRadius="8"> - + + + + + - + + + + + + + + + + + + diff --git a/src/ServerMonitorManager.Desktop/MainPage.xaml.cs b/src/ServerMonitorManager.Desktop/MainPage.xaml.cs index 1043cb8..9fe4b65 100644 --- a/src/ServerMonitorManager.Desktop/MainPage.xaml.cs +++ b/src/ServerMonitorManager.Desktop/MainPage.xaml.cs @@ -756,7 +756,7 @@ public sealed partial class MainPage : Page MeshNodes.Add(new MeshNodeViewModel( agent.NodeId, "Control", - age <= 90 ? "online" : "offline", + agent.Status == "Revoked" ? "revoked" : age <= 90 ? "online" : "offline", age)); } @@ -799,6 +799,94 @@ public sealed partial class MainPage : Page private async void DisconnectLinkButton_Click(object sender, RoutedEventArgs e) => await ChangeLinkAsync(enable: false); + private async void ReenrollNodeButton_Click(object sender, RoutedEventArgs e) + { + if (!_control.IsConfigured) + { + ShowInfo( + "Control Hub не подключён", + "Перерегистрация доступна после подключения через SMMDEV1.", + InfoBarSeverity.Warning); + return; + } + + if (SourceNodeBox.SelectedItem is not MeshNodeViewModel node) + { + ShowInfo( + "Выберите Node", + "Выберите сервер в поле «Источник доступа».", + InfoBarSeverity.Warning); + return; + } + + var confirmation = new ContentDialog + { + XamlRoot = XamlRoot, + Title = $"Перерегистрировать {node.Name}?", + Content = "Старый mTLS-сертификат будет немедленно отозван. Все связанные Links получат желаемое состояние Disabled. Новый token действует 10 минут.", + PrimaryButtonText = "Отозвать и создать token", + CloseButtonText = "Отмена", + DefaultButton = ContentDialogButton.Close + }; + if (await confirmation.ShowAsync() != ContentDialogResult.Primary) + { + return; + } + + try + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(20)); + var ticket = await _control.ReenrollAgentAsync( + node.Name, + "Windows operator requested certificate rotation", + timeout.Token); + await RefreshMeshAsync(showSuccess: false); + var tokenBox = new TextBox + { + Header = "Одноразовый enrollment token", + Text = ticket.Token, + IsReadOnly = true, + TextWrapping = TextWrapping.Wrap + }; + var result = await new ContentDialog + { + XamlRoot = XamlRoot, + Title = $"{ticket.EntityId} ожидает перерегистрацию", + Content = new StackPanel + { + Spacing = 12, + Children = + { + new TextBlock + { + Text = $"Token действителен до {ticket.ExpiresAt.ToLocalTime():dd.MM.yyyy HH:mm}. Отключено Links: {ticket.DisabledLinks}. Передайте token только администратору этого Node.", + TextWrapping = TextWrapping.Wrap + }, + tokenBox + } + }, + PrimaryButtonText = "Копировать token", + CloseButtonText = "Закрыть", + DefaultButton = ContentDialogButton.Close + }.ShowAsync(); + if (result == ContentDialogResult.Primary) + { + var package = new DataPackage(); + package.SetText(ticket.Token); + Clipboard.SetContent(package); + Clipboard.Flush(); + ShowInfo( + "Token скопирован", + "Он действует 10 минут и предназначен только для выбранного Node.", + InfoBarSeverity.Success); + } + } + catch (Exception exception) + { + ShowInfo("Перерегистрация не выполнена", CompactError(exception), InfoBarSeverity.Error); + } + } + private async Task ChangeLinkAsync(bool enable) { var useControl = _control.IsConfigured; diff --git a/tests/ServerMonitorManager.Control.Tests/ControlStoreTests.cs b/tests/ServerMonitorManager.Control.Tests/ControlStoreTests.cs index b268446..13cf12b 100644 --- a/tests/ServerMonitorManager.Control.Tests/ControlStoreTests.cs +++ b/tests/ServerMonitorManager.Control.Tests/ControlStoreTests.cs @@ -176,6 +176,91 @@ public sealed class ControlStoreTests : IAsyncDisposable eventTypes); } + [Fact] + public async Task AgentReenrollmentRevokesCertificateAndDisablesLinksBeforeIssuingToken() + { + var cancellationToken = TestContext.Current.CancellationToken; + var store = CreateStore(); + await store.InitializeAsync(cancellationToken); + await EnrollAgentAsync(store, "ai-agent", "AA22", cancellationToken); + await EnrollAgentAsync(store, "home", "BB33", cancellationToken); + var broker = new ControlEventBroker(); + var applier = new CheckingPolicyApplier(store); + var links = new LinkService(store, applier, broker); + await links.CreateAsync( + new LinkPolicyCreateRequest( + "ai-agent", "home", "tcp", 22, 60, "development", Guid.NewGuid().ToString()), + "windows-pc", + cancellationToken); + var lifecycle = new CertificateLifecycleService(store, applier, broker); + var request = new CertificateReenrollmentRequest("rotate compromised key", Guid.NewGuid().ToString()); + + var ticket = await lifecycle.ReenrollAgentAsync( + "ai-agent", request, "windows-pc", cancellationToken); + var replay = await lifecycle.ReenrollAgentAsync( + "ai-agent", request, "windows-pc", cancellationToken); + var disabledLink = Assert.Single(await store.ListLinksAsync(cancellationToken)); + + Assert.NotNull(ticket); + Assert.Equal(ticket, replay); + Assert.Equal("Agent", ticket.EntityType); + Assert.Equal("ai-agent", ticket.EntityId); + Assert.Equal(1, ticket.DisabledLinks); + Assert.True(ticket.ExpiresAt > DateTimeOffset.UtcNow); + Assert.False(await store.IsCertificateForNodeAsync("AA22", "ai-agent", cancellationToken)); + Assert.Equal("Disabled", disabledLink.DesiredState); + Assert.Equal("Disabled", disabledLink.ActualState); + Assert.Equal(1, applier.DisconnectCalls); + + var issued = new IssuedCertificate( + "new-certificate", "ca", "CC44", DateTimeOffset.UtcNow.AddYears(1)); + var enrolled = await store.EnrollAsync( + new EnrollmentRequest( + "ai-agent", ticket.Token, "new-csr", Guid.NewGuid().ToString()), + () => issued, + cancellationToken); + Assert.NotNull(enrolled); + Assert.True(await store.IsCertificateForNodeAsync("CC44", "ai-agent", cancellationToken)); + Assert.False(await store.IsCertificateForNodeAsync("AA22", "ai-agent", cancellationToken)); + } + + [Fact] + public async Task DeviceReenrollmentRevokesOldOperatorAndIsIdempotent() + { + var cancellationToken = TestContext.Current.CancellationToken; + var store = CreateStore(); + await store.InitializeAsync(cancellationToken); + var initialToken = await store.CreateDeviceEnrollmentTokenAsync( + "windows-pc", TimeSpan.FromMinutes(10), cancellationToken); + await store.EnrollDeviceAsync( + new DeviceEnrollmentRequest( + "windows-pc", initialToken, "old-csr", Guid.NewGuid().ToString()), + () => new IssuedCertificate( + "old-certificate", "ca", "DD55", DateTimeOffset.UtcNow.AddYears(1)), + cancellationToken); + var request = new CertificateReenrollmentRequest("scheduled rotation", Guid.NewGuid().ToString()); + + var ticket = await store.BeginDeviceReenrollmentAsync( + "windows-pc", request, "windows-pc", TimeSpan.FromMinutes(10), cancellationToken); + var replay = await store.BeginDeviceReenrollmentAsync( + "windows-pc", request, "windows-pc", TimeSpan.FromMinutes(10), cancellationToken); + + Assert.NotNull(ticket); + Assert.Equal(ticket, replay); + Assert.Null(await store.ResolveIdentityAsync("DD55", cancellationToken)); + var enrolled = await store.EnrollDeviceAsync( + new DeviceEnrollmentRequest( + "windows-pc", ticket.Token, "new-csr", Guid.NewGuid().ToString()), + () => new IssuedCertificate( + "new-certificate", "ca", "EE66", DateTimeOffset.UtcNow.AddYears(1)), + cancellationToken); + Assert.NotNull(enrolled); + Assert.Equal( + new ControlIdentity("windows-pc", "Operator"), + await store.ResolveIdentityAsync("EE66", cancellationToken)); + Assert.Null(await store.ResolveIdentityAsync("DD55", cancellationToken)); + } + public ValueTask DisposeAsync() { SqliteConnection.ClearAllPools();