diff --git a/.github/workflows/linux-control-agent.yml b/.github/workflows/linux-control-agent.yml
new file mode 100644
index 0000000..f8f55ae
--- /dev/null
+++ b/.github/workflows/linux-control-agent.yml
@@ -0,0 +1,39 @@
+name: Linux control and agent
+
+on:
+ push:
+ branches: ['**']
+ pull_request:
+
+permissions:
+ contents: read
+
+jobs:
+ build-and-test:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+
+ - name: Set up .NET 10
+ uses: actions/setup-dotnet@v5
+ with:
+ dotnet-version: 10.0.x
+
+ - name: Restore
+ run: dotnet restore ServerMonitorManager.slnx
+
+ - name: Build
+ run: dotnet build ServerMonitorManager.slnx --configuration Release --no-restore
+
+ - name: Test
+ run: dotnet test tests/ServerMonitorManager.Control.Tests/ServerMonitorManager.Control.Tests.csproj --configuration Release --no-build
+
+ - name: Verify formatting
+ run: dotnet format ServerMonitorManager.slnx --verify-no-changes --no-restore
+
+ - name: Publish agent amd64
+ run: dotnet publish src/ServerMonitorManager.Agent/ServerMonitorManager.Agent.csproj --configuration Release --runtime linux-x64 --self-contained true -p:PublishSingleFile=true -p:PublishTrimmed=true
+
+ - name: Publish agent arm64
+ run: dotnet publish src/ServerMonitorManager.Agent/ServerMonitorManager.Agent.csproj --configuration Release --runtime linux-arm64 --self-contained true -p:PublishSingleFile=true -p:PublishTrimmed=true
diff --git a/.github/workflows/linux-release.yml b/.github/workflows/linux-release.yml
new file mode 100644
index 0000000..6e5beb7
--- /dev/null
+++ b/.github/workflows/linux-release.yml
@@ -0,0 +1,55 @@
+name: Linux release artifacts
+
+on:
+ workflow_dispatch:
+ push:
+ tags:
+ - 'v*'
+
+permissions:
+ contents: write
+
+jobs:
+ publish:
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ runtime: [linux-x64, linux-arm64]
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+
+ - name: Set up .NET 10
+ uses: actions/setup-dotnet@v5
+ with:
+ dotnet-version: 10.0.x
+
+ - name: Publish agent
+ run: dotnet publish src/ServerMonitorManager.Agent/ServerMonitorManager.Agent.csproj --configuration Release --runtime ${{ matrix.runtime }} --self-contained true -p:PublishSingleFile=true -p:PublishTrimmed=true -o out/agent
+
+ - name: Publish control
+ run: dotnet publish src/ServerMonitorManager.Control/ServerMonitorManager.Control.csproj --configuration Release --runtime ${{ matrix.runtime }} --self-contained true -p:PublishSingleFile=true -p:PublishTrimmed=true -o out/control
+
+ - name: Package
+ shell: bash
+ run: |
+ set -Eeuo pipefail
+ archive="server-monitor-manager-${{ matrix.runtime }}.tar.gz"
+ tar -C out -czf "$archive" agent control
+ sha256sum "$archive" > "$archive.sha256"
+
+ - name: Upload artifact
+ uses: actions/upload-artifact@v6
+ with:
+ name: server-monitor-manager-${{ matrix.runtime }}
+ path: |
+ server-monitor-manager-${{ matrix.runtime }}.tar.gz
+ server-monitor-manager-${{ matrix.runtime }}.tar.gz.sha256
+
+ - name: Attach to GitHub Release
+ if: startsWith(github.ref, 'refs/tags/')
+ uses: softprops/action-gh-release@v2
+ with:
+ files: |
+ server-monitor-manager-${{ matrix.runtime }}.tar.gz
+ server-monitor-manager-${{ matrix.runtime }}.tar.gz.sha256
diff --git a/.gitignore b/.gitignore
index b24738d..fb8b25f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,6 +4,7 @@
bin/
obj/
artifacts/
+.artifacts/
TestResults/
*.user
*.suo
diff --git a/README.md b/README.md
index 82054c3..64f8236 100644
--- a/README.md
+++ b/README.md
@@ -45,6 +45,28 @@ tests/
Установочный скрипт Linux-части хранится в репозитории [`ochenstarik-ui/lightweight-server`](https://github.com/ochenstarik-ui/lightweight-server) под именем `ochenstarik-server-monitor-manager.sh`. Он устанавливает режим Hub или Node, создаёт отдельного пользователя `ochenstarik-monitor` и SSH forced-command: ключ приложения не получает shell, PTY, port forwarding или право выполнять произвольные команды.
+В разработческой ветке также появился первый срез постоянного control layer: ASP.NET Core 10 Hub, SQLite и исходящий Linux Agent. Регистрация использует одноразовый token и CSR, дальнейшие heartbeat-запросы — mTLS. Этот слой ещё не заменяет проверенный SSH/WireGuard установщик: сначала будут добавлены release-бинарники, systemd-установка и миграция Links.
+
+Alpha-установка постоянного слоя после обычных ролей Hub/Node:
+
+```bash
+# Hub
+sudo ./ochenstarik-server-monitor-manager.sh install-control-hub
+sudo ./ochenstarik-server-monitor-manager.sh control-code home
+
+# соответствующий Node — вставить полученный SMMCTL1
+sudo ./ochenstarik-server-monitor-manager.sh install-control-agent
+```
+
+Архив выбирается автоматически для amd64 или arm64 и проверяется по SHA-256. Для Control Hub требуется входящий TCP-порт `7443`; Agent открытых входящих портов не создаёт.
+
+Проверка control layer для разработчиков:
+
+```bash
+dotnet build ServerMonitorManager.slnx --configuration Release
+dotnet test tests/ServerMonitorManager.Control.Tests/ServerMonitorManager.Control.Tests.csproj --configuration Release
+```
+
## Быстрый тест на трёх серверах
1. Запустите Windows-клиент и нажмите `SSH-ключ` → `Копировать`.
diff --git a/ServerMonitorManager.slnx b/ServerMonitorManager.slnx
new file mode 100644
index 0000000..25c13cb
--- /dev/null
+++ b/ServerMonitorManager.slnx
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/deploy/ochenstarik-smm-agent.service b/deploy/ochenstarik-smm-agent.service
new file mode 100644
index 0000000..3e7659f
--- /dev/null
+++ b/deploy/ochenstarik-smm-agent.service
@@ -0,0 +1,28 @@
+[Unit]
+Description=Ochenstarik Server Monitor Manager Agent
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+Type=simple
+User=ochenstarik-smm-agent
+Group=ochenstarik-smm-agent
+EnvironmentFile=/etc/ochenstarik-server-monitor-manager/agent.env
+ExecStart=/usr/local/lib/ochenstarik-server-monitor-manager/agent/ochenstarik-smm-agent
+Restart=on-failure
+RestartSec=10s
+NoNewPrivileges=true
+PrivateTmp=true
+PrivateDevices=true
+ProtectSystem=strict
+ProtectHome=true
+ProtectKernelTunables=true
+ProtectKernelModules=true
+ProtectControlGroups=true
+LockPersonality=true
+RestrictSUIDSGID=true
+RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
+ReadWritePaths=/var/lib/ochenstarik-server-monitor-manager/agent
+
+[Install]
+WantedBy=multi-user.target
diff --git a/deploy/ochenstarik-smm-control.service b/deploy/ochenstarik-smm-control.service
new file mode 100644
index 0000000..7b3f95d
--- /dev/null
+++ b/deploy/ochenstarik-smm-control.service
@@ -0,0 +1,28 @@
+[Unit]
+Description=Ochenstarik Server Monitor Manager Control Hub
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+Type=simple
+User=ochenstarik-smm-control
+Group=ochenstarik-smm-control
+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
+ProtectHome=true
+ProtectKernelTunables=true
+ProtectKernelModules=true
+ProtectControlGroups=true
+LockPersonality=true
+RestrictSUIDSGID=true
+RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
+ReadWritePaths=/var/lib/ochenstarik-server-monitor-manager
+
+[Install]
+WantedBy=multi-user.target
diff --git a/docs/architecture.md b/docs/architecture.md
index 4912ef5..a7bbfd1 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -96,6 +96,10 @@ Active -> Expired -> Disabled
Hub остаётся маршрутизатором Mesh первого поколения. Разделение control plane и data plane возможно позже без изменения модели направленных Links.
+Первый реализованный срез 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 выполняются следующими частями этапа.
+
## 7. Целевые ограничения MVP
- без Kubernetes и обязательного Docker;
diff --git a/docs/installer-contract.md b/docs/installer-contract.md
index 20ae4c6..f8aa33f 100644
--- a/docs/installer-contract.md
+++ b/docs/installer-contract.md
@@ -38,6 +38,16 @@
- создаёт только исходящее WireGuard-соединение;
- не требует белого IP или входящего публичного порта.
+## Постоянный control layer (alpha)
+
+После установки существующих ролей отдельные действия добавляют постоянные сервисы:
+
+- `install-control-hub` скачивает release-архив под amd64/arm64, проверяет SHA-256, создаёт локальный CA, HTTPS-сертификат Hub, SQLite-каталог и изолированный systemd service;
+- `control-code NAME` создаёт десятиминутный token и код `SMMCTL1`, содержащий URL Hub и только публичный CA;
+- `install-control-agent` проверяет CA, локально создаёт ключ и CSR, регистрирует сертификат и запускает исходящий mTLS Agent через systemd.
+
+Приватный ключ Control CA не включается в `SMMCTL1`, а приватный ключ Agent не покидает Node. По умолчанию Control Hub слушает TCP `7443`.
+
## Команды жизненного цикла
Целевой интерфейс:
@@ -46,6 +56,9 @@
install-monitor
install-hub
install-node
+install-control-hub
+install-control-agent
+control-code NAME
status
update
rollback
diff --git a/docs/roadmap.md b/docs/roadmap.md
index db261de..7a05f73 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -70,12 +70,12 @@
## Этап 6 — постоянный control layer
-- [ ] статический Linux agent для amd64/arm64;
+- [x] самодостаточный single-file Linux agent для amd64/arm64;
- [ ] SQLite inventory, policies, history и audit;
-- [ ] исходящие mTLS agent sessions;
+- [x] исходящие mTLS agent sessions;
- [ ] WebSocket/stream событий для desktop client;
- [ ] ограниченный локальный буфер и downsampling;
-- [ ] idempotency key и защита от replay;
+- [x] idempotency key и защита от replay;
- [ ] тест нагрузки 50–100 Node на одном Hub.
## Этап 7 — релиз и другие платформы
diff --git a/src/ServerMonitorManager.Agent/AgentClient.cs b/src/ServerMonitorManager.Agent/AgentClient.cs
new file mode 100644
index 0000000..44c9a89
--- /dev/null
+++ b/src/ServerMonitorManager.Agent/AgentClient.cs
@@ -0,0 +1,105 @@
+using System.Net.Http.Json;
+using System.Security.Cryptography;
+using System.Security.Cryptography.X509Certificates;
+using ServerMonitorManager.Core;
+
+namespace ServerMonitorManager.Agent;
+
+internal sealed class AgentClient(AgentOptions options)
+{
+ private readonly string _certificatePath = Path.Combine(options.StateDirectory, "agent.pfx");
+
+ public async Task EnrollAsync(string token, CancellationToken cancellationToken)
+ {
+ Directory.CreateDirectory(options.StateDirectory);
+ using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
+ var request = new CertificateRequest(
+ $"CN={options.NodeId}",
+ key,
+ HashAlgorithmName.SHA256);
+ var enrollment = new EnrollmentRequest(
+ options.NodeId,
+ token,
+ request.CreateSigningRequestPem(),
+ Guid.NewGuid().ToString());
+
+ using var client = CreateHttpClient(clientCertificate: null);
+ using var response = await client.PostAsJsonAsync(
+ "api/v1/enroll",
+ enrollment,
+ SmmJsonContext.Default.EnrollmentRequest,
+ cancellationToken);
+ response.EnsureSuccessStatusCode();
+ var result = await response.Content.ReadFromJsonAsync(
+ SmmJsonContext.Default.EnrollmentResponse,
+ cancellationToken)
+ ?? throw new InvalidOperationException("Control service returned an empty enrollment response.");
+ using var certificate = X509Certificate2.CreateFromPem(result.CertificatePem, key.ExportPkcs8PrivateKeyPem());
+ await File.WriteAllBytesAsync(_certificatePath, certificate.Export(X509ContentType.Pfx), cancellationToken);
+ await File.WriteAllTextAsync(options.CertificateAuthorityPath, result.CertificateAuthorityPem, cancellationToken);
+ SetOwnerOnlyPermissions(_certificatePath);
+ SetOwnerOnlyPermissions(options.CertificateAuthorityPath);
+ }
+
+ public async Task RunAsync(CancellationToken cancellationToken)
+ {
+ using var certificate = X509CertificateLoader.LoadPkcs12FromFile(
+ _certificatePath,
+ password: null,
+ X509KeyStorageFlags.EphemeralKeySet);
+ using var client = CreateHttpClient(certificate);
+ var delay = TimeSpan.FromSeconds(options.HeartbeatSeconds);
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ var heartbeat = LinuxMetrics.Collect(options.NodeId, "0.1.0");
+ using var response = await client.PostAsJsonAsync(
+ "api/v1/agents/heartbeat",
+ heartbeat,
+ SmmJsonContext.Default.AgentHeartbeat,
+ cancellationToken);
+ response.EnsureSuccessStatusCode();
+ var accepted = await response.Content.ReadFromJsonAsync(
+ SmmJsonContext.Default.AgentHeartbeatResponse,
+ cancellationToken)
+ ?? throw new InvalidOperationException("Control service returned an empty heartbeat response.");
+ delay = TimeSpan.FromSeconds(Math.Clamp(accepted.NextHeartbeatSeconds, 10, 300));
+ await Task.Delay(delay, cancellationToken);
+ }
+ }
+
+ private HttpClient CreateHttpClient(X509Certificate2? clientCertificate)
+ {
+ using var root = X509CertificateLoader.LoadCertificateFromFile(options.CertificateAuthorityPath);
+ var rootBytes = root.Export(X509ContentType.Cert);
+ 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 = options.ControlUrl };
+ }
+
+ private static void SetOwnerOnlyPermissions(string path)
+ {
+ if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS())
+ {
+ File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite);
+ }
+ }
+}
diff --git a/src/ServerMonitorManager.Agent/AgentOptions.cs b/src/ServerMonitorManager.Agent/AgentOptions.cs
new file mode 100644
index 0000000..7d91d78
--- /dev/null
+++ b/src/ServerMonitorManager.Agent/AgentOptions.cs
@@ -0,0 +1,10 @@
+namespace ServerMonitorManager.Agent;
+
+public sealed class AgentOptions
+{
+ public string NodeId { get; init; } = Environment.MachineName.ToLowerInvariant();
+ public Uri ControlUrl { get; init; } = new("https://127.0.0.1:7443");
+ public string StateDirectory { get; init; } = "/var/lib/ochenstarik-server-monitor-manager/agent";
+ public string CertificateAuthorityPath { get; init; } = "/etc/ochenstarik-server-monitor-manager/control-ca.crt";
+ public int HeartbeatSeconds { get; init; } = 30;
+}
diff --git a/src/ServerMonitorManager.Agent/LinuxMetrics.cs b/src/ServerMonitorManager.Agent/LinuxMetrics.cs
new file mode 100644
index 0000000..f79893b
--- /dev/null
+++ b/src/ServerMonitorManager.Agent/LinuxMetrics.cs
@@ -0,0 +1,78 @@
+using ServerMonitorManager.Core;
+
+namespace ServerMonitorManager.Agent;
+
+internal static class LinuxMetrics
+{
+ public static AgentHeartbeat Collect(string nodeId, string version)
+ {
+ var memory = ReadMemory();
+ var disk = new DriveInfo("/");
+ var network = ReadNetwork();
+ return new AgentHeartbeat(
+ nodeId,
+ version,
+ DateTimeOffset.UtcNow,
+ ReadLoadOne(),
+ memory.Total - memory.Available,
+ memory.Total,
+ disk.TotalSize - disk.AvailableFreeSpace,
+ disk.TotalSize,
+ network.Receive,
+ network.Transmit,
+ ReadUptimeSeconds(),
+ Guid.NewGuid().ToString());
+ }
+
+ private static double ReadLoadOne()
+ {
+ var value = File.ReadAllText("/proc/loadavg").Split(' ', 2)[0];
+ return double.Parse(value, System.Globalization.CultureInfo.InvariantCulture);
+ }
+
+ private static long ReadUptimeSeconds()
+ {
+ var value = File.ReadAllText("/proc/uptime").Split(' ', 2)[0];
+ return (long)double.Parse(value, System.Globalization.CultureInfo.InvariantCulture);
+ }
+
+ private static (long Total, long Available) ReadMemory()
+ {
+ long total = 0;
+ long available = 0;
+ foreach (var line in File.ReadLines("/proc/meminfo"))
+ {
+ var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);
+ if (parts.Length < 2)
+ {
+ continue;
+ }
+ if (parts[0] == "MemTotal:")
+ {
+ total = long.Parse(parts[1]) * 1024;
+ }
+ else if (parts[0] == "MemAvailable:")
+ {
+ available = long.Parse(parts[1]) * 1024;
+ }
+ }
+ return (total, available);
+ }
+
+ private static (long Receive, long Transmit) ReadNetwork()
+ {
+ long receive = 0;
+ long transmit = 0;
+ foreach (var line in File.ReadLines("/proc/net/dev").Skip(2))
+ {
+ var parts = line.Split([':', ' '], StringSplitOptions.RemoveEmptyEntries);
+ if (parts.Length < 10 || parts[0] == "lo")
+ {
+ continue;
+ }
+ receive += long.Parse(parts[1]);
+ transmit += long.Parse(parts[9]);
+ }
+ return (receive, transmit);
+ }
+}
diff --git a/src/ServerMonitorManager.Agent/Program.cs b/src/ServerMonitorManager.Agent/Program.cs
new file mode 100644
index 0000000..9b0d1ad
--- /dev/null
+++ b/src/ServerMonitorManager.Agent/Program.cs
@@ -0,0 +1,33 @@
+using Microsoft.Extensions.Configuration;
+using ServerMonitorManager.Agent;
+
+var configuration = new ConfigurationBuilder()
+ .AddJsonFile("appsettings.json", optional: true)
+ .AddEnvironmentVariables("SMM_")
+ .AddCommandLine(args)
+ .Build();
+var options = configuration.Get() ?? new AgentOptions();
+if (string.IsNullOrWhiteSpace(options.NodeId)
+ || !options.NodeId.All(character => character is >= 'a' and <= 'z' or >= '0' and <= '9' or '-'))
+{
+ Console.Error.WriteLine("NodeId must contain lowercase letters, digits, or hyphens.");
+ return 2;
+}
+
+using var shutdown = new CancellationTokenSource();
+Console.CancelKeyPress += (_, eventArgs) =>
+{
+ eventArgs.Cancel = true;
+ shutdown.Cancel();
+};
+var client = new AgentClient(options);
+var enrollmentToken = configuration["EnrollToken"];
+if (!string.IsNullOrWhiteSpace(enrollmentToken))
+{
+ await client.EnrollAsync(enrollmentToken, shutdown.Token);
+ Console.WriteLine("Agent enrollment completed.");
+ return 0;
+}
+
+await client.RunAsync(shutdown.Token);
+return 0;
diff --git a/src/ServerMonitorManager.Agent/ServerMonitorManager.Agent.csproj b/src/ServerMonitorManager.Agent/ServerMonitorManager.Agent.csproj
new file mode 100644
index 0000000..8f46603
--- /dev/null
+++ b/src/ServerMonitorManager.Agent/ServerMonitorManager.Agent.csproj
@@ -0,0 +1,21 @@
+
+
+ Exe
+ net10.0
+ enable
+ enable
+ true
+ 0.1.0
+ ochenstarik-smm-agent
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ServerMonitorManager.Agent/appsettings.json b/src/ServerMonitorManager.Agent/appsettings.json
new file mode 100644
index 0000000..362a265
--- /dev/null
+++ b/src/ServerMonitorManager.Agent/appsettings.json
@@ -0,0 +1,7 @@
+{
+ "NodeId": "change-me",
+ "ControlUrl": "https://127.0.0.1:7443",
+ "StateDirectory": "/var/lib/ochenstarik-server-monitor-manager/agent",
+ "CertificateAuthorityPath": "/etc/ochenstarik-server-monitor-manager/control-ca.crt",
+ "HeartbeatSeconds": 30
+}
diff --git a/src/ServerMonitorManager.Control/CertificateAuthority.cs b/src/ServerMonitorManager.Control/CertificateAuthority.cs
new file mode 100644
index 0000000..6ec9a63
--- /dev/null
+++ b/src/ServerMonitorManager.Control/CertificateAuthority.cs
@@ -0,0 +1,75 @@
+using System.Security.Cryptography;
+using System.Security.Cryptography.X509Certificates;
+using Microsoft.Extensions.Options;
+
+namespace ServerMonitorManager.Control;
+
+public sealed class CertificateAuthority : IDisposable
+{
+ private readonly X509Certificate2 _issuer;
+
+ public CertificateAuthority(IOptions options)
+ {
+ var value = options.Value;
+ _issuer = X509CertificateLoader.LoadPkcs12FromFile(
+ value.CertificateAuthorityPath,
+ value.CertificateAuthorityPassword,
+ X509KeyStorageFlags.EphemeralKeySet);
+ if (!_issuer.HasPrivateKey)
+ {
+ throw new InvalidOperationException("Control CA certificate must contain its private key.");
+ }
+ }
+
+ public X509Certificate2 PublicCertificate => _issuer;
+
+ public IssuedCertificate IssueClientCertificate(string nodeId, string csrPem)
+ {
+ var request = CertificateRequest.LoadSigningRequestPem(
+ csrPem,
+ HashAlgorithmName.SHA256,
+ CertificateRequestLoadOptions.UnsafeLoadCertificateExtensions);
+ if (!string.Equals(request.SubjectName.Name, $"CN={nodeId}", StringComparison.Ordinal))
+ {
+ throw new InvalidOperationException("CSR subject does not match the requested node id.");
+ }
+ if (request.CertificateExtensions.Count != 0)
+ {
+ throw new InvalidOperationException("CSR extensions are not accepted.");
+ }
+
+ request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, true));
+ request.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, true));
+ request.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(
+ new OidCollection { new("1.3.6.1.5.5.7.3.2") },
+ true));
+ request.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(request.PublicKey, false));
+
+ var serial = RandomNumberGenerator.GetBytes(20);
+ var issuerNotBefore = new DateTimeOffset(_issuer.NotBefore.ToUniversalTime(), TimeSpan.Zero);
+ var issuerNotAfter = new DateTimeOffset(_issuer.NotAfter.ToUniversalTime(), TimeSpan.Zero);
+ var notBefore = DateTimeOffset.UtcNow.AddMinutes(-2) > issuerNotBefore
+ ? DateTimeOffset.UtcNow.AddMinutes(-2)
+ : issuerNotBefore;
+ var requestedNotAfter = DateTimeOffset.UtcNow.AddYears(1);
+ var notAfter = requestedNotAfter < issuerNotAfter ? requestedNotAfter : issuerNotAfter;
+ if (notAfter <= notBefore)
+ {
+ throw new InvalidOperationException("Control CA certificate is expired or not yet valid.");
+ }
+ using var certificate = request.Create(_issuer, notBefore, notAfter, serial);
+ return new IssuedCertificate(
+ certificate.ExportCertificatePem(),
+ _issuer.ExportCertificatePem(),
+ certificate.Thumbprint,
+ notAfter);
+ }
+
+ public void Dispose() => _issuer.Dispose();
+}
+
+public sealed record IssuedCertificate(
+ string CertificatePem,
+ string CertificateAuthorityPem,
+ string Thumbprint,
+ DateTimeOffset ExpiresAt);
diff --git a/src/ServerMonitorManager.Control/ControlOptions.cs b/src/ServerMonitorManager.Control/ControlOptions.cs
new file mode 100644
index 0000000..a9bb1c1
--- /dev/null
+++ b/src/ServerMonitorManager.Control/ControlOptions.cs
@@ -0,0 +1,14 @@
+namespace ServerMonitorManager.Control;
+
+public sealed class ControlOptions
+{
+ public const string SectionName = "Control";
+
+ public string DatabasePath { get; init; } = "/var/lib/ochenstarik-server-monitor-manager/control.db";
+
+ public string CertificateAuthorityPath { get; init; } = "/etc/ochenstarik-server-monitor-manager/control-ca.pfx";
+
+ public string? CertificateAuthorityPassword { get; init; }
+
+ public int HeartbeatSeconds { get; init; } = 30;
+}
diff --git a/src/ServerMonitorManager.Control/ControlStore.cs b/src/ServerMonitorManager.Control/ControlStore.cs
new file mode 100644
index 0000000..c925441
--- /dev/null
+++ b/src/ServerMonitorManager.Control/ControlStore.cs
@@ -0,0 +1,354 @@
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization.Metadata;
+using Microsoft.Data.Sqlite;
+using Microsoft.Extensions.Options;
+using ServerMonitorManager.Core;
+
+namespace ServerMonitorManager.Control;
+
+public sealed class ControlStore(IOptions options)
+{
+ private readonly string _connectionString = new SqliteConnectionStringBuilder
+ {
+ DataSource = options.Value.DatabasePath,
+ Mode = SqliteOpenMode.ReadWriteCreate,
+ Cache = SqliteCacheMode.Shared
+ }.ToString();
+
+ public async Task InitializeAsync(CancellationToken cancellationToken = default)
+ {
+ var path = new SqliteConnectionStringBuilder(_connectionString).DataSource;
+ Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(path))!);
+ await using var connection = await OpenAsync(cancellationToken);
+ var command = connection.CreateCommand();
+ command.CommandText = """
+ PRAGMA journal_mode = WAL;
+ PRAGMA foreign_keys = ON;
+ CREATE TABLE IF NOT EXISTS enrollment_tokens (
+ token_hash TEXT PRIMARY KEY,
+ node_id TEXT NOT NULL,
+ expires_at TEXT NOT NULL,
+ consumed_at TEXT NULL
+ );
+ CREATE TABLE IF NOT EXISTS agents (
+ node_id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ certificate_thumbprint TEXT NOT NULL UNIQUE,
+ certificate_expires_at TEXT NOT NULL,
+ status TEXT NOT NULL,
+ agent_version TEXT NOT NULL DEFAULT '',
+ 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,
+ recorded_at TEXT NOT NULL,
+ payload_json TEXT NOT NULL
+ );
+ CREATE INDEX IF NOT EXISTS ix_metric_samples_node_time
+ ON metric_samples(node_id, recorded_at DESC);
+ CREATE TABLE IF NOT EXISTS idempotency (
+ operation_key TEXT PRIMARY KEY,
+ request_hash TEXT NOT NULL,
+ response_json TEXT NOT NULL,
+ created_at TEXT NOT NULL
+ );
+ CREATE TABLE IF NOT EXISTS audit (
+ sequence INTEGER PRIMARY KEY AUTOINCREMENT,
+ recorded_at TEXT NOT NULL,
+ actor TEXT NOT NULL,
+ action TEXT NOT NULL,
+ subject TEXT NOT NULL,
+ details_json TEXT NOT NULL
+ );
+ """;
+ await command.ExecuteNonQueryAsync(cancellationToken);
+ }
+
+ public async Task CreateEnrollmentTokenAsync(
+ string nodeId,
+ 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 enrollment_tokens(token_hash, node_id, expires_at)
+ VALUES ($hash, $node, $expires);
+ """;
+ command.Parameters.AddWithValue("$hash", Hash(token));
+ command.Parameters.AddWithValue("$node", nodeId);
+ command.Parameters.AddWithValue("$expires", DateTimeOffset.UtcNow.Add(lifetime).ToString("O"));
+ await command.ExecuteNonQueryAsync(cancellationToken);
+ return token;
+ }
+
+ public async Task EnrollAsync(
+ EnrollmentRequest 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,
+ $"enroll:{request.IdempotencyKey}",
+ Fingerprint(request, SmmJsonContext.Default.EnrollmentRequest),
+ SmmJsonContext.Default.EnrollmentResponse,
+ cancellationToken);
+ if (cached is not null)
+ {
+ await transaction.CommitAsync(cancellationToken);
+ return cached;
+ }
+
+ var consume = connection.CreateCommand();
+ consume.Transaction = transaction;
+ consume.CommandText = """
+ UPDATE enrollment_tokens
+ SET consumed_at = $now
+ WHERE token_hash = $hash
+ AND node_id = $node
+ AND consumed_at IS NULL
+ AND expires_at >= $now;
+ """;
+ var now = DateTimeOffset.UtcNow.ToString("O");
+ consume.Parameters.AddWithValue("$now", now);
+ consume.Parameters.AddWithValue("$hash", Hash(request.Token));
+ consume.Parameters.AddWithValue("$node", request.NodeId);
+ if (await consume.ExecuteNonQueryAsync(cancellationToken) != 1)
+ {
+ await transaction.RollbackAsync(cancellationToken);
+ return null;
+ }
+
+ var issued = issueCertificate();
+ var response = new EnrollmentResponse(
+ request.NodeId,
+ issued.CertificatePem,
+ issued.CertificateAuthorityPem,
+ issued.ExpiresAt);
+ var upsert = connection.CreateCommand();
+ upsert.Transaction = transaction;
+ upsert.CommandText = """
+ INSERT INTO agents(node_id, name, certificate_thumbprint, certificate_expires_at, status)
+ VALUES ($node, $node, $thumbprint, $expires, 'Enrolled')
+ ON CONFLICT(node_id) DO UPDATE SET
+ certificate_thumbprint = excluded.certificate_thumbprint,
+ certificate_expires_at = excluded.certificate_expires_at,
+ status = 'Enrolled';
+ """;
+ upsert.Parameters.AddWithValue("$node", request.NodeId);
+ upsert.Parameters.AddWithValue("$thumbprint", issued.Thumbprint);
+ upsert.Parameters.AddWithValue("$expires", issued.ExpiresAt.ToString("O"));
+ await upsert.ExecuteNonQueryAsync(cancellationToken);
+ await WriteIdempotentAsync(
+ connection,
+ transaction,
+ $"enroll:{request.IdempotencyKey}",
+ Fingerprint(request, SmmJsonContext.Default.EnrollmentRequest),
+ response,
+ SmmJsonContext.Default.EnrollmentResponse,
+ cancellationToken);
+ await WriteAuditAsync(connection, transaction, request.NodeId, "agent.enroll", request.NodeId, "{}", cancellationToken);
+ await transaction.CommitAsync(cancellationToken);
+ return response;
+ }
+
+ public async Task IsCertificateActiveAsync(
+ 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');
+ """;
+ command.Parameters.AddWithValue("$thumbprint", thumbprint);
+ command.Parameters.AddWithValue("$now", DateTimeOffset.UtcNow.ToString("O"));
+ return Convert.ToInt32(await command.ExecuteScalarAsync(cancellationToken)) == 1;
+ }
+
+ public async Task IsCertificateForNodeAsync(
+ string thumbprint,
+ string nodeId,
+ 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 node_id = $node
+ AND certificate_expires_at > $now
+ AND status != 'Revoked');
+ """;
+ command.Parameters.AddWithValue("$thumbprint", thumbprint);
+ command.Parameters.AddWithValue("$node", nodeId);
+ command.Parameters.AddWithValue("$now", DateTimeOffset.UtcNow.ToString("O"));
+ return Convert.ToInt32(await command.ExecuteScalarAsync(cancellationToken)) == 1;
+ }
+
+ public async Task RecordHeartbeatAsync(
+ AgentHeartbeat heartbeat,
+ int nextHeartbeatSeconds,
+ 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,
+ $"heartbeat:{heartbeat.NodeId}:{heartbeat.IdempotencyKey}",
+ Fingerprint(heartbeat, SmmJsonContext.Default.AgentHeartbeat),
+ SmmJsonContext.Default.AgentHeartbeatResponse,
+ cancellationToken);
+ if (cached is not null)
+ {
+ await transaction.CommitAsync(cancellationToken);
+ return cached;
+ }
+
+ var now = DateTimeOffset.UtcNow;
+ var update = connection.CreateCommand();
+ update.Transaction = transaction;
+ update.CommandText = """
+ UPDATE agents
+ SET status = 'Online', agent_version = $version, last_seen_at = $now
+ WHERE node_id = $node;
+ """;
+ update.Parameters.AddWithValue("$version", heartbeat.AgentVersion);
+ update.Parameters.AddWithValue("$now", now.ToString("O"));
+ update.Parameters.AddWithValue("$node", heartbeat.NodeId);
+ if (await update.ExecuteNonQueryAsync(cancellationToken) != 1)
+ {
+ throw new InvalidOperationException("Unknown agent node id.");
+ }
+
+ var insert = connection.CreateCommand();
+ insert.Transaction = transaction;
+ insert.CommandText = """
+ INSERT INTO metric_samples(node_id, recorded_at, payload_json)
+ VALUES ($node, $now, $payload);
+ SELECT last_insert_rowid();
+ """;
+ insert.Parameters.AddWithValue("$node", heartbeat.NodeId);
+ insert.Parameters.AddWithValue("$now", now.ToString("O"));
+ insert.Parameters.AddWithValue(
+ "$payload",
+ JsonSerializer.Serialize(heartbeat, SmmJsonContext.Default.AgentHeartbeat));
+ var sequence = Convert.ToInt64(await insert.ExecuteScalarAsync(cancellationToken));
+ var response = new AgentHeartbeatResponse(now, sequence, nextHeartbeatSeconds);
+ await WriteIdempotentAsync(
+ connection,
+ transaction,
+ $"heartbeat:{heartbeat.NodeId}:{heartbeat.IdempotencyKey}",
+ Fingerprint(heartbeat, SmmJsonContext.Default.AgentHeartbeat),
+ response,
+ SmmJsonContext.Default.AgentHeartbeatResponse,
+ cancellationToken);
+ await transaction.CommitAsync(cancellationToken);
+ return response;
+ }
+
+ private async Task OpenAsync(CancellationToken cancellationToken)
+ {
+ var connection = new SqliteConnection(_connectionString);
+ await connection.OpenAsync(cancellationToken);
+ return connection;
+ }
+
+ private static string Hash(string value)
+ => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value)));
+
+ private static string Fingerprint(T value, JsonTypeInfo typeInfo)
+ => Hash(JsonSerializer.Serialize(value, typeInfo));
+
+ private static async Task ReadIdempotentAsync(
+ SqliteConnection connection,
+ SqliteTransaction transaction,
+ string key,
+ string requestHash,
+ JsonTypeInfo typeInfo,
+ CancellationToken cancellationToken)
+ {
+ var command = connection.CreateCommand();
+ command.Transaction = transaction;
+ command.CommandText = "SELECT request_hash, response_json FROM idempotency WHERE operation_key = $key;";
+ command.Parameters.AddWithValue("$key", key);
+ await using var reader = await command.ExecuteReaderAsync(cancellationToken);
+ if (!await reader.ReadAsync(cancellationToken))
+ {
+ return default;
+ }
+ if (!string.Equals(reader.GetString(0), requestHash, StringComparison.Ordinal))
+ {
+ throw new IdempotencyConflictException();
+ }
+ return JsonSerializer.Deserialize(reader.GetString(1), typeInfo);
+ }
+
+ private static async Task WriteIdempotentAsync(
+ SqliteConnection connection,
+ SqliteTransaction transaction,
+ string key,
+ string requestHash,
+ T response,
+ JsonTypeInfo typeInfo,
+ CancellationToken cancellationToken)
+ {
+ var command = connection.CreateCommand();
+ command.Transaction = transaction;
+ command.CommandText = """
+ INSERT INTO idempotency(operation_key, request_hash, response_json, created_at)
+ VALUES ($key, $requestHash, $response, $now);
+ """;
+ command.Parameters.AddWithValue("$key", key);
+ command.Parameters.AddWithValue("$requestHash", requestHash);
+ command.Parameters.AddWithValue("$response", JsonSerializer.Serialize(response, typeInfo));
+ command.Parameters.AddWithValue("$now", DateTimeOffset.UtcNow.ToString("O"));
+ await command.ExecuteNonQueryAsync(cancellationToken);
+ }
+
+ private static async Task WriteAuditAsync(
+ SqliteConnection connection,
+ SqliteTransaction transaction,
+ string actor,
+ string action,
+ string subject,
+ string details,
+ CancellationToken cancellationToken)
+ {
+ var command = connection.CreateCommand();
+ command.Transaction = transaction;
+ command.CommandText = """
+ INSERT INTO audit(recorded_at, actor, action, subject, details_json)
+ VALUES ($now, $actor, $action, $subject, $details);
+ """;
+ command.Parameters.AddWithValue("$now", DateTimeOffset.UtcNow.ToString("O"));
+ command.Parameters.AddWithValue("$actor", actor);
+ command.Parameters.AddWithValue("$action", action);
+ command.Parameters.AddWithValue("$subject", subject);
+ command.Parameters.AddWithValue("$details", details);
+ await command.ExecuteNonQueryAsync(cancellationToken);
+ }
+}
+
+public sealed class IdempotencyConflictException : Exception
+{
+ public IdempotencyConflictException()
+ : base("Idempotency key was reused with a different request.")
+ {
+ }
+}
diff --git a/src/ServerMonitorManager.Control/Program.cs b/src/ServerMonitorManager.Control/Program.cs
new file mode 100644
index 0000000..30a5a67
--- /dev/null
+++ b/src/ServerMonitorManager.Control/Program.cs
@@ -0,0 +1,217 @@
+using System.Security.Claims;
+using System.Security.Cryptography;
+using System.Security.Cryptography.X509Certificates;
+using System.Threading.RateLimiting;
+using Microsoft.AspNetCore.Authentication.Certificate;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Server.Kestrel.Https;
+using Microsoft.Extensions.Options;
+using ServerMonitorManager.Control;
+using ServerMonitorManager.Core;
+
+var builder = WebApplication.CreateBuilder(args);
+builder.Services.AddProblemDetails();
+builder.Services.AddHealthChecks();
+builder.Services.AddRateLimiter(options =>
+{
+ options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
+ options.AddPolicy("enrollment", context => RateLimitPartition.GetFixedWindowLimiter(
+ context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
+ _ => new FixedWindowRateLimiterOptions
+ {
+ PermitLimit = 10,
+ Window = TimeSpan.FromMinutes(1),
+ QueueLimit = 0,
+ AutoReplenishment = true
+ }));
+});
+builder.Services.ConfigureHttpJsonOptions(options =>
+ options.SerializerOptions.TypeInfoResolverChain.Insert(0, SmmJsonContext.Default));
+builder.Services.AddOptions()
+ .Bind(builder.Configuration.GetSection(ControlOptions.SectionName))
+ .Validate(options =>
+ !string.IsNullOrWhiteSpace(options.DatabasePath)
+ && !string.IsNullOrWhiteSpace(options.CertificateAuthorityPath)
+ && options.HeartbeatSeconds is >= 10 and <= 300,
+ "Control paths are required and HeartbeatSeconds must be between 10 and 300.")
+ .ValidateOnStart();
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+builder.Services.AddAuthentication(CertificateAuthenticationDefaults.AuthenticationScheme)
+ .AddCertificate(options =>
+ {
+ options.AllowedCertificateTypes = CertificateTypes.All;
+ options.RevocationMode = X509RevocationMode.NoCheck;
+ options.ValidateCertificateUse = true;
+ options.ValidateValidityPeriod = true;
+ options.Events = new CertificateAuthenticationEvents
+ {
+ OnCertificateValidated = async context =>
+ {
+ var store = context.HttpContext.RequestServices.GetRequiredService();
+ if (!await store.IsCertificateActiveAsync(context.ClientCertificate.Thumbprint))
+ {
+ context.Fail("The agent certificate is unknown, expired, or revoked.");
+ return;
+ }
+
+ context.Principal = new ClaimsPrincipal(new ClaimsIdentity(
+ [new Claim(ClaimTypes.NameIdentifier, context.ClientCertificate.Thumbprint)],
+ context.Scheme.Name));
+ context.Success();
+ }
+ };
+ });
+builder.Services.AddOptions(
+ CertificateAuthenticationDefaults.AuthenticationScheme)
+ .Configure((options, authority) =>
+ {
+ options.ChainTrustValidationMode = X509ChainTrustMode.CustomRootTrust;
+ options.CustomTrustStore.Add(authority.PublicCertificate);
+ });
+builder.Services.AddAuthorization();
+builder.WebHost.ConfigureKestrel(options =>
+{
+ options.Limits.MaxRequestBodySize = 64 * 1024;
+ options.ConfigureHttpsDefaults(https =>
+ https.ClientCertificateMode = ClientCertificateMode.AllowCertificate);
+});
+
+var app = builder.Build();
+app.UseExceptionHandler();
+app.UseHsts();
+app.UseRateLimiter();
+app.UseAuthentication();
+app.UseAuthorization();
+
+var store = app.Services.GetRequiredService();
+await store.InitializeAsync();
+
+if (args is ["token-create", var nodeId])
+{
+ if (!NodeIdValidator.IsValid(nodeId))
+ {
+ Console.Error.WriteLine("Node id must contain 1-63 lowercase letters, digits, or hyphens.");
+ return 2;
+ }
+
+ Console.WriteLine(await store.CreateEnrollmentTokenAsync(nodeId, TimeSpan.FromMinutes(10)));
+ return 0;
+}
+
+app.MapHealthChecks("/healthz").AllowAnonymous();
+
+app.MapPost("/api/v1/enroll", async (
+ EnrollmentRequest request,
+ ControlStore controlStore,
+ CertificateAuthority authority,
+ CancellationToken cancellationToken) =>
+{
+ if (!NodeIdValidator.IsValid(request.NodeId)
+ || string.IsNullOrWhiteSpace(request.Token)
+ || string.IsNullOrWhiteSpace(request.CertificateSigningRequestPem)
+ || !IdempotencyKeyValidator.IsValid(request.IdempotencyKey))
+ {
+ return Results.ValidationProblem(new Dictionary
+ {
+ ["request"] = ["Invalid enrollment request."]
+ });
+ }
+
+ EnrollmentResponse? response;
+ try
+ {
+ response = await controlStore.EnrollAsync(
+ request,
+ () => authority.IssueClientCertificate(request.NodeId, request.CertificateSigningRequestPem),
+ cancellationToken);
+ }
+ 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."]
+ });
+ }
+
+ return response is null
+ ? Results.Unauthorized()
+ : Results.Ok(response);
+}).AllowAnonymous().RequireRateLimiting("enrollment");
+
+var agents = app.MapGroup("/api/v1/agents").RequireAuthorization();
+agents.MapPost("/heartbeat", async (
+ AgentHeartbeat heartbeat,
+ HttpContext context,
+ ControlStore controlStore,
+ IOptions options,
+ CancellationToken cancellationToken) =>
+{
+ if (!NodeIdValidator.IsValid(heartbeat.NodeId)
+ || !IdempotencyKeyValidator.IsValid(heartbeat.IdempotencyKey)
+ || heartbeat.SentAt < DateTimeOffset.UtcNow.AddMinutes(-5)
+ || heartbeat.SentAt > DateTimeOffset.UtcNow.AddMinutes(1))
+ {
+ return Results.ValidationProblem(new Dictionary
+ {
+ ["heartbeat"] = ["Invalid or stale heartbeat."]
+ });
+ }
+
+ var certificate = await context.Connection.GetClientCertificateAsync(cancellationToken);
+ if (certificate is null)
+ {
+ return Results.Unauthorized();
+ }
+
+ if (!await controlStore.IsCertificateForNodeAsync(
+ certificate.Thumbprint,
+ heartbeat.NodeId,
+ cancellationToken))
+ {
+ return Results.Forbid();
+ }
+
+ try
+ {
+ var response = await controlStore.RecordHeartbeatAsync(
+ heartbeat,
+ options.Value.HeartbeatSeconds,
+ cancellationToken);
+ return Results.Ok(response);
+ }
+ catch (IdempotencyConflictException)
+ {
+ return Results.Conflict(new ProblemDetails
+ {
+ Title = "Idempotency key conflict",
+ Status = StatusCodes.Status409Conflict
+ });
+ }
+});
+
+await app.RunAsync();
+return 0;
+
+public partial class Program;
+
+internal static class NodeIdValidator
+{
+ public static bool IsValid(string value)
+ => value.Length is >= 1 and <= 63
+ && value.All(character => character is >= 'a' and <= 'z' or >= '0' and <= '9' or '-');
+}
+
+internal static class IdempotencyKeyValidator
+{
+ public static bool IsValid(string value)
+ => Guid.TryParse(value, out _);
+}
diff --git a/src/ServerMonitorManager.Control/ServerMonitorManager.Control.csproj b/src/ServerMonitorManager.Control/ServerMonitorManager.Control.csproj
new file mode 100644
index 0000000..0c9b790
--- /dev/null
+++ b/src/ServerMonitorManager.Control/ServerMonitorManager.Control.csproj
@@ -0,0 +1,19 @@
+
+
+ net10.0
+ enable
+ enable
+ true
+ 0.1.0
+ ochenstarik-smm-control
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ServerMonitorManager.Control/appsettings.json b/src/ServerMonitorManager.Control/appsettings.json
new file mode 100644
index 0000000..5df2287
--- /dev/null
+++ b/src/ServerMonitorManager.Control/appsettings.json
@@ -0,0 +1,15 @@
+{
+ "Control": {
+ "DatabasePath": "/var/lib/ochenstarik-server-monitor-manager/control.db",
+ "CertificateAuthorityPath": "/etc/ochenstarik-server-monitor-manager/control-ca.pfx",
+ "CertificateAuthorityPassword": null,
+ "HeartbeatSeconds": 30
+ },
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*"
+}
diff --git a/src/ServerMonitorManager.Core/Contracts.cs b/src/ServerMonitorManager.Core/Contracts.cs
new file mode 100644
index 0000000..fb8452c
--- /dev/null
+++ b/src/ServerMonitorManager.Core/Contracts.cs
@@ -0,0 +1,39 @@
+namespace ServerMonitorManager.Core;
+
+public sealed record EnrollmentRequest(
+ string NodeId,
+ string Token,
+ string CertificateSigningRequestPem,
+ string IdempotencyKey);
+
+public sealed record EnrollmentResponse(
+ string NodeId,
+ string CertificatePem,
+ string CertificateAuthorityPem,
+ DateTimeOffset ExpiresAt);
+
+public sealed record AgentHeartbeat(
+ string NodeId,
+ string AgentVersion,
+ DateTimeOffset SentAt,
+ double LoadOne,
+ long MemoryUsedBytes,
+ long MemoryTotalBytes,
+ long DiskUsedBytes,
+ long DiskTotalBytes,
+ long NetworkReceiveBytes,
+ long NetworkTransmitBytes,
+ long UptimeSeconds,
+ string IdempotencyKey);
+
+public sealed record AgentHeartbeatResponse(
+ DateTimeOffset AcceptedAt,
+ long Sequence,
+ int NextHeartbeatSeconds);
+
+public sealed record AgentSummary(
+ string NodeId,
+ string Name,
+ string Status,
+ string AgentVersion,
+ DateTimeOffset? LastSeenAt);
diff --git a/src/ServerMonitorManager.Core/ServerMonitorManager.Core.csproj b/src/ServerMonitorManager.Core/ServerMonitorManager.Core.csproj
new file mode 100644
index 0000000..6c3a887
--- /dev/null
+++ b/src/ServerMonitorManager.Core/ServerMonitorManager.Core.csproj
@@ -0,0 +1,7 @@
+
+
+ net10.0
+ enable
+ enable
+
+
diff --git a/src/ServerMonitorManager.Core/SmmJsonContext.cs b/src/ServerMonitorManager.Core/SmmJsonContext.cs
new file mode 100644
index 0000000..f125bb6
--- /dev/null
+++ b/src/ServerMonitorManager.Core/SmmJsonContext.cs
@@ -0,0 +1,10 @@
+using System.Text.Json.Serialization;
+
+namespace ServerMonitorManager.Core;
+
+[JsonSerializable(typeof(EnrollmentRequest))]
+[JsonSerializable(typeof(EnrollmentResponse))]
+[JsonSerializable(typeof(AgentHeartbeat))]
+[JsonSerializable(typeof(AgentHeartbeatResponse))]
+[JsonSerializable(typeof(AgentSummary[]))]
+public sealed partial class SmmJsonContext : JsonSerializerContext;
diff --git a/tests/ServerMonitorManager.Control.Tests/CertificateAuthorityTests.cs b/tests/ServerMonitorManager.Control.Tests/CertificateAuthorityTests.cs
new file mode 100644
index 0000000..ceda103
--- /dev/null
+++ b/tests/ServerMonitorManager.Control.Tests/CertificateAuthorityTests.cs
@@ -0,0 +1,59 @@
+using System.Security.Cryptography;
+using System.Security.Cryptography.X509Certificates;
+using Microsoft.Extensions.Options;
+using ServerMonitorManager.Control;
+using Xunit;
+
+namespace ServerMonitorManager.Control.Tests;
+
+public sealed class CertificateAuthorityTests : IDisposable
+{
+ private readonly string _directory = Path.Combine(Path.GetTempPath(), $"smm-ca-tests-{Guid.NewGuid():N}");
+
+ [Fact]
+ public void IssuedAgentCertificateChainsToControlAuthority()
+ {
+ Directory.CreateDirectory(_directory);
+ var caPath = Path.Combine(_directory, "control-ca.pfx");
+ using var caKey = ECDsa.Create(ECCurve.NamedCurves.nistP256);
+ var caRequest = new CertificateRequest("CN=SMM Test CA", caKey, HashAlgorithmName.SHA256);
+ caRequest.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true));
+ caRequest.CertificateExtensions.Add(new X509KeyUsageExtension(
+ X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign,
+ true));
+ using var ca = caRequest.CreateSelfSigned(
+ DateTimeOffset.UtcNow.AddMinutes(-1),
+ DateTimeOffset.UtcNow.AddYears(2));
+ File.WriteAllBytes(caPath, ca.Export(X509ContentType.Pfx));
+
+ using var authority = new CertificateAuthority(Options.Create(new ControlOptions
+ {
+ DatabasePath = Path.Combine(_directory, "unused.db"),
+ CertificateAuthorityPath = caPath
+ }));
+ using var agentKey = ECDsa.Create(ECCurve.NamedCurves.nistP256);
+ var agentRequest = new CertificateRequest("CN=home", agentKey, HashAlgorithmName.SHA256);
+ var issued = authority.IssueClientCertificate("home", agentRequest.CreateSigningRequestPem());
+ using var agentCertificate = X509Certificate2.CreateFromPem(
+ issued.CertificatePem,
+ agentKey.ExportPkcs8PrivateKeyPem());
+ using var chain = new X509Chain();
+ chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
+ chain.ChainPolicy.CustomTrustStore.Add(authority.PublicCertificate);
+ chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
+
+ Assert.True(chain.Build(agentCertificate));
+ Assert.Contains(
+ agentCertificate.Extensions.OfType()
+ .SelectMany(extension => extension.EnhancedKeyUsages.Cast()),
+ oid => oid.Value == "1.3.6.1.5.5.7.3.2");
+ }
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_directory))
+ {
+ Directory.Delete(_directory, recursive: true);
+ }
+ }
+}
diff --git a/tests/ServerMonitorManager.Control.Tests/ControlStoreTests.cs b/tests/ServerMonitorManager.Control.Tests/ControlStoreTests.cs
new file mode 100644
index 0000000..7201f25
--- /dev/null
+++ b/tests/ServerMonitorManager.Control.Tests/ControlStoreTests.cs
@@ -0,0 +1,84 @@
+using Microsoft.Extensions.Options;
+using Microsoft.Data.Sqlite;
+using ServerMonitorManager.Control;
+using ServerMonitorManager.Core;
+using Xunit;
+
+namespace ServerMonitorManager.Control.Tests;
+
+public sealed class ControlStoreTests : IAsyncDisposable
+{
+ private readonly string _directory = Path.Combine(Path.GetTempPath(), $"smm-tests-{Guid.NewGuid():N}");
+
+ [Fact]
+ public async Task EnrollmentTokenIsAtomicAndIdempotent()
+ {
+ var cancellationToken = TestContext.Current.CancellationToken;
+ var store = CreateStore();
+ await store.InitializeAsync(cancellationToken);
+ var token = await store.CreateEnrollmentTokenAsync("home", TimeSpan.FromMinutes(10), cancellationToken);
+ var idempotencyKey = Guid.NewGuid().ToString();
+ var request = new EnrollmentRequest("home", token, "unused-in-store-test", idempotencyKey);
+ var issued = new IssuedCertificate("certificate", "ca", "AA11", DateTimeOffset.UtcNow.AddYears(1));
+
+ var first = await store.EnrollAsync(request, () => issued, cancellationToken);
+ var retry = await store.EnrollAsync(request, () => throw new InvalidOperationException("must use cache"), cancellationToken);
+ var replay = await store.EnrollAsync(
+ request with { IdempotencyKey = Guid.NewGuid().ToString() },
+ () => issued,
+ cancellationToken);
+
+ Assert.NotNull(first);
+ Assert.Equal(first, retry);
+ Assert.Null(replay);
+ Assert.True(await store.IsCertificateForNodeAsync("AA11", "home", cancellationToken));
+ Assert.False(await store.IsCertificateForNodeAsync("AA11", "other", cancellationToken));
+ }
+
+ [Fact]
+ public async Task HeartbeatRetryDoesNotDuplicateMetricSample()
+ {
+ var cancellationToken = TestContext.Current.CancellationToken;
+ var store = CreateStore();
+ await store.InitializeAsync(cancellationToken);
+ var token = await store.CreateEnrollmentTokenAsync("home", TimeSpan.FromMinutes(10), cancellationToken);
+ var issued = new IssuedCertificate("certificate", "ca", "BB22", DateTimeOffset.UtcNow.AddYears(1));
+ await store.EnrollAsync(
+ new EnrollmentRequest("home", token, "csr", Guid.NewGuid().ToString()),
+ () => issued,
+ cancellationToken);
+ var heartbeat = new AgentHeartbeat(
+ "home", "test", DateTimeOffset.UtcNow, 0.5, 1, 2, 3, 4, 5, 6, 7,
+ Guid.NewGuid().ToString());
+
+ var first = await store.RecordHeartbeatAsync(heartbeat, 30, cancellationToken);
+ var retry = await store.RecordHeartbeatAsync(heartbeat, 30, cancellationToken);
+
+ Assert.Equal(first, retry);
+ Assert.Equal(1, first.Sequence);
+ await Assert.ThrowsAsync(() => store.RecordHeartbeatAsync(
+ heartbeat with { LoadOne = 0.9 },
+ 30,
+ cancellationToken));
+ }
+
+ public ValueTask DisposeAsync()
+ {
+ SqliteConnection.ClearAllPools();
+ if (Directory.Exists(_directory))
+ {
+ Directory.Delete(_directory, recursive: true);
+ }
+ return ValueTask.CompletedTask;
+ }
+
+ private ControlStore CreateStore()
+ {
+ Directory.CreateDirectory(_directory);
+ return new ControlStore(Options.Create(new ControlOptions
+ {
+ DatabasePath = Path.Combine(_directory, "control.db"),
+ CertificateAuthorityPath = Path.Combine(_directory, "unused.pfx")
+ }));
+ }
+}
diff --git a/tests/ServerMonitorManager.Control.Tests/ServerMonitorManager.Control.Tests.csproj b/tests/ServerMonitorManager.Control.Tests/ServerMonitorManager.Control.Tests.csproj
new file mode 100644
index 0000000..642fd9e
--- /dev/null
+++ b/tests/ServerMonitorManager.Control.Tests/ServerMonitorManager.Control.Tests.csproj
@@ -0,0 +1,14 @@
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+