Add Windows SSH monitoring MVP #1

Merged
ochenstarik-ui merged 23 commits from agent/windows-ssh-monitoring into main 2026-07-16 15:25:19 +00:00
21 changed files with 1584 additions and 20 deletions
Showing only changes of commit 1e5e44a6ad - Show all commits

View file

@ -53,6 +53,7 @@ Alpha-установка постоянного слоя после обычны
# Hub
sudo ./ochenstarik-server-monitor-manager.sh install-control-hub
sudo ./ochenstarik-server-monitor-manager.sh control-code home
sudo ./ochenstarik-server-monitor-manager.sh control-device-code windows-pc
# соответствующий Node — вставить полученный SMMCTL1
sudo ./ochenstarik-server-monitor-manager.sh install-control-agent
@ -60,6 +61,10 @@ sudo ./ochenstarik-server-monitor-manager.sh install-control-agent
Архив выбирается автоматически для amd64 или arm64 и проверяется по SHA-256. Для Control Hub требуется входящий TCP-порт `7443`; Agent открытых входящих портов не создаёт.
Код `SMMDEV1` предназначен только для Windows-клиента: приложение локально создаёт operator key и получает отдельный mTLS-сертификат. Agent identities не могут читать весь inventory, изменять Links или подключаться к потоку операторских событий.
После регистрации кнопка `Control Hub` сохраняет operator certificate через DPAPI. Панель Mesh автоматически переключается с переходного SSH-протокола на SQLite Control API, а изменения Links и heartbeat поступают через защищённый поток событий без ручного опроса интерфейса.
Проверка control layer для разработчиков:
```bash

View file

@ -11,7 +11,6 @@ EnvironmentFile=/etc/ochenstarik-server-monitor-manager/control.env
ExecStart=/usr/local/lib/ochenstarik-server-monitor-manager/control/ochenstarik-smm-control
Restart=on-failure
RestartSec=10s
NoNewPrivileges=true
PrivateTmp=true
PrivateDevices=true
ProtectSystem=strict

View file

@ -98,7 +98,9 @@ Hub остаётся маршрутизатором Mesh первого поко
Первый реализованный срез control layer использует ASP.NET Core 10 и SQLite. Hub выдаёт агенту сертификат по CSR только после атомарного погашения десятиминутного token. После регистрации Agent выполняет только исходящие HTTPS-запросы с mTLS, а Hub связывает thumbprint сертификата с конкретным `node_id`. Heartbeat содержит idempotency key и отклоняется при попытке повторить тот же ключ с другим телом запроса.
Сейчас этот срез сохраняет inventory, heartbeat-метрики, idempotency и аудит регистрации. Перенос политик Links из файлов в SQLite, постоянный event stream и локальный буфер Agent выполняются следующими частями этапа.
Control Hub сохраняет inventory, heartbeat-метрики, направленные Links, idempotency и аудит в SQLite. Желаемое состояние отключения Link фиксируется до вызова ограниченного nftables wrapper; при ошибке фактическое состояние становится `Partial`. Отдельная mTLS identity `Operator` читает inventory, управляет Links и получает NDJSON event stream, а сертификат `Agent` ограничен heartbeat собственного `node_id`.
Локальный буфер Agent, downsampling и подключение event stream к WinUI выполняются следующими частями этапа.
## 7. Целевые ограничения MVP

View file

@ -44,10 +44,13 @@
- `install-control-hub` скачивает release-архив под amd64/arm64, проверяет SHA-256, создаёт локальный CA, HTTPS-сертификат Hub, SQLite-каталог и изолированный systemd service;
- `control-code NAME` создаёт десятиминутный token и код `SMMCTL1`, содержащий URL Hub и только публичный CA;
- `control-device-code DEVICE` создаёт отдельный код `SMMDEV1` для operator identity Windows-клиента;
- `install-control-agent` проверяет CA, локально создаёт ключ и CSR, регистрирует сертификат и запускает исходящий mTLS Agent через systemd.
Приватный ключ Control CA не включается в `SMMCTL1`, а приватный ключ Agent не покидает Node. По умолчанию Control Hub слушает TCP `7443`.
Control service не получает общий доступ к root helper. Отдельный root-owned wrapper принимает только проверенные `link-connect` и `link-disconnect`; команды регистрации, удаления Node и произвольные аргументы ему недоступны.
## Команды жизненного цикла
Целевой интерфейс:
@ -59,6 +62,7 @@ install-node
install-control-hub
install-control-agent
control-code NAME
control-device-code DEVICE
status
update
rollback

View file

@ -41,7 +41,7 @@
- [x] срок действия не более 10 минут;
- [x] атомарное погашение token;
- [ ] отзыв и повторная регистрация Node;
- [ ] подтверждение fingerprint Hub;
- [x] подтверждение SHA-256 fingerprint Control CA Hub;
- [x] защита desktop SSH-ключа через DPAPI.
## Этап 4 — управляемые Links
@ -71,9 +71,9 @@
## Этап 6 — постоянный control layer
- [x] самодостаточный single-file Linux agent для amd64/arm64;
- [ ] SQLite inventory, policies, history и audit;
- [x] SQLite inventory, policies, history и audit;
- [x] исходящие mTLS agent sessions;
- [ ] WebSocket/stream событий для desktop client;
- [x] защищённый Hub event stream для desktop client;
- [ ] ограниченный локальный буфер и downsampling;
- [x] idempotency key и защита от replay;
- [ ] тест нагрузки 50100 Node на одном Hub.

View file

@ -29,6 +29,12 @@ Windows-клиент использует отдельный Ed25519-ключ т
До появления mTLS enrollment выполняется через отдельную ограниченную SSH-команду. Token не должен содержать приватный WireGuard-ключ.
## Operator identity Windows-клиента
Windows-клиент получает отдельный код `SMMDEV1`, показывает URL и SHA-256 fingerprint Control CA и продолжает регистрацию только после явного подтверждения пользователя. Приватный operator key создаётся локально, хранится в DPAPI current-user scope и не используется как SSH monitoring, terminal или Agent identity.
Сертификат `Agent` разрешает только heartbeat собственного `node_id`. Только сертификат с ролью `Operator` может читать общий inventory, изменять Links и подписываться на event stream. Control service вызывает от root только отдельный wrapper с командами `link-connect` и `link-disconnect`; другие Hub-команды через него запрещены.
## Авторизация Links
- мониторинг read-only отделён от управления;

View file

@ -0,0 +1,46 @@
using System.Collections.Concurrent;
using System.Threading.Channels;
using ServerMonitorManager.Core;
namespace ServerMonitorManager.Control;
public sealed class ControlEventBroker
{
private readonly ConcurrentDictionary<Guid, Channel<ControlEvent>> _subscribers = new();
private long _sequence;
public ControlEvent Publish(string type, string subject, string payloadJson)
{
var controlEvent = new ControlEvent(
Interlocked.Increment(ref _sequence),
type,
subject,
DateTimeOffset.UtcNow,
payloadJson);
foreach (var channel in _subscribers.Values)
{
channel.Writer.TryWrite(controlEvent);
}
return controlEvent;
}
public ControlEventSubscription Subscribe()
{
var id = Guid.NewGuid();
var channel = Channel.CreateBounded<ControlEvent>(new BoundedChannelOptions(256)
{
FullMode = BoundedChannelFullMode.DropOldest,
SingleReader = true,
SingleWriter = false
});
_subscribers[id] = channel;
return new ControlEventSubscription(channel.Reader, () => _subscribers.TryRemove(id, out _));
}
}
public sealed class ControlEventSubscription(ChannelReader<ControlEvent> reader, Action unsubscribe) : IDisposable
{
public ChannelReader<ControlEvent> Reader { get; } = reader;
public void Dispose() => unsubscribe();
}

View file

@ -11,4 +11,6 @@ public sealed class ControlOptions
public string? CertificateAuthorityPassword { get; init; }
public int HeartbeatSeconds { get; init; } = 30;
public string HubHelperPath { get; init; } = "/usr/local/libexec/ochenstarik-smm-policy-apply";
}

View file

@ -41,6 +41,19 @@ public sealed class ControlStore(IOptions<ControlOptions> options)
agent_version TEXT NOT NULL DEFAULT '',
last_seen_at TEXT NULL
);
CREATE TABLE IF NOT EXISTS device_tokens (
token_hash TEXT PRIMARY KEY,
device_id TEXT NOT NULL,
expires_at TEXT NOT NULL,
consumed_at TEXT NULL
);
CREATE TABLE IF NOT EXISTS devices (
device_id TEXT PRIMARY KEY,
certificate_thumbprint TEXT NOT NULL UNIQUE,
certificate_expires_at TEXT NOT NULL,
status TEXT NOT NULL,
last_seen_at TEXT NULL
);
CREATE TABLE IF NOT EXISTS metric_samples (
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
node_id TEXT NOT NULL REFERENCES agents(node_id) ON DELETE CASCADE,
@ -63,6 +76,25 @@ public sealed class ControlStore(IOptions<ControlOptions> options)
subject TEXT NOT NULL,
details_json TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS links (
id TEXT PRIMARY KEY,
source_node_id TEXT NOT NULL REFERENCES agents(node_id),
target_node_id TEXT NOT NULL REFERENCES agents(node_id),
protocol TEXT NOT NULL,
port INTEGER NOT NULL,
ttl_minutes INTEGER NOT NULL,
reason TEXT NOT NULL,
desired_state TEXT NOT NULL,
actual_state TEXT NOT NULL,
version INTEGER NOT NULL,
created_at TEXT NOT NULL,
expires_at TEXT NULL,
updated_at TEXT NOT NULL,
last_error TEXT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS ux_links_active_policy
ON links(source_node_id, target_node_id, protocol, port)
WHERE desired_state = 'Active';
""";
await command.ExecuteNonQueryAsync(cancellationToken);
}
@ -87,6 +119,100 @@ public sealed class ControlStore(IOptions<ControlOptions> options)
return token;
}
public async Task<string> CreateDeviceEnrollmentTokenAsync(
string deviceId,
TimeSpan lifetime,
CancellationToken cancellationToken = default)
{
var token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32))
.TrimEnd('=').Replace('+', '-').Replace('/', '_');
await using var connection = await OpenAsync(cancellationToken);
var command = connection.CreateCommand();
command.CommandText = """
INSERT INTO device_tokens(token_hash, device_id, expires_at)
VALUES ($hash, $device, $expires);
""";
command.Parameters.AddWithValue("$hash", Hash(token));
command.Parameters.AddWithValue("$device", deviceId);
command.Parameters.AddWithValue("$expires", DateTimeOffset.UtcNow.Add(lifetime).ToString("O"));
await command.ExecuteNonQueryAsync(cancellationToken);
return token;
}
public async Task<DeviceEnrollmentResponse?> EnrollDeviceAsync(
DeviceEnrollmentRequest request,
Func<IssuedCertificate> issueCertificate,
CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken);
await using var transaction = (SqliteTransaction)await connection.BeginTransactionAsync(cancellationToken);
var cached = await ReadIdempotentAsync<DeviceEnrollmentResponse>(
connection,
transaction,
$"device-enroll:{request.IdempotencyKey}",
Fingerprint(request, SmmJsonContext.Default.DeviceEnrollmentRequest),
SmmJsonContext.Default.DeviceEnrollmentResponse,
cancellationToken);
if (cached is not null)
{
await transaction.CommitAsync(cancellationToken);
return cached;
}
var now = DateTimeOffset.UtcNow.ToString("O");
var consume = connection.CreateCommand();
consume.Transaction = transaction;
consume.CommandText = """
UPDATE device_tokens
SET consumed_at = $now
WHERE token_hash = $hash
AND device_id = $device
AND consumed_at IS NULL
AND expires_at >= $now;
""";
consume.Parameters.AddWithValue("$now", now);
consume.Parameters.AddWithValue("$hash", Hash(request.Token));
consume.Parameters.AddWithValue("$device", request.DeviceId);
if (await consume.ExecuteNonQueryAsync(cancellationToken) != 1)
{
await transaction.RollbackAsync(cancellationToken);
return null;
}
var issued = issueCertificate();
var response = new DeviceEnrollmentResponse(
request.DeviceId,
issued.CertificatePem,
issued.CertificateAuthorityPem,
issued.ExpiresAt);
var upsert = connection.CreateCommand();
upsert.Transaction = transaction;
upsert.CommandText = """
INSERT INTO devices(device_id, certificate_thumbprint, certificate_expires_at, status)
VALUES ($device, $thumbprint, $expires, 'Active')
ON CONFLICT(device_id) DO UPDATE SET
certificate_thumbprint = excluded.certificate_thumbprint,
certificate_expires_at = excluded.certificate_expires_at,
status = 'Active';
""";
upsert.Parameters.AddWithValue("$device", request.DeviceId);
upsert.Parameters.AddWithValue("$thumbprint", issued.Thumbprint);
upsert.Parameters.AddWithValue("$expires", issued.ExpiresAt.ToString("O"));
await upsert.ExecuteNonQueryAsync(cancellationToken);
await WriteIdempotentAsync(
connection,
transaction,
$"device-enroll:{request.IdempotencyKey}",
Fingerprint(request, SmmJsonContext.Default.DeviceEnrollmentRequest),
response,
SmmJsonContext.Default.DeviceEnrollmentResponse,
cancellationToken);
await WriteAuditAsync(
connection, transaction, request.DeviceId, "device.enroll", request.DeviceId, "{}", cancellationToken);
await transaction.CommitAsync(cancellationToken);
return response;
}
public async Task<EnrollmentResponse?> EnrollAsync(
EnrollmentRequest request,
Func<IssuedCertificate> issueCertificate,
@ -164,19 +290,34 @@ public sealed class ControlStore(IOptions<ControlOptions> options)
public async Task<bool> IsCertificateActiveAsync(
string thumbprint,
CancellationToken cancellationToken = default)
{
return await ResolveIdentityAsync(thumbprint, cancellationToken) is not null;
}
public async Task<ControlIdentity?> ResolveIdentityAsync(
string thumbprint,
CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken);
var command = connection.CreateCommand();
command.CommandText = """
SELECT EXISTS(
SELECT 1 FROM agents
WHERE certificate_thumbprint = $thumbprint
AND certificate_expires_at > $now
AND status != 'Revoked');
SELECT node_id, 'Agent' FROM agents
WHERE certificate_thumbprint = $thumbprint
AND certificate_expires_at > $now
AND status != 'Revoked'
UNION ALL
SELECT device_id, 'Operator' FROM devices
WHERE certificate_thumbprint = $thumbprint
AND certificate_expires_at > $now
AND status != 'Revoked'
LIMIT 1;
""";
command.Parameters.AddWithValue("$thumbprint", thumbprint);
command.Parameters.AddWithValue("$now", DateTimeOffset.UtcNow.ToString("O"));
return Convert.ToInt32(await command.ExecuteScalarAsync(cancellationToken)) == 1;
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
return await reader.ReadAsync(cancellationToken)
? new ControlIdentity(reader.GetString(0), reader.GetString(1))
: null;
}
public async Task<bool> IsCertificateForNodeAsync(
@ -262,6 +403,284 @@ public sealed class ControlStore(IOptions<ControlOptions> options)
return response;
}
public async Task<IReadOnlyList<AgentSummary>> ListAgentsAsync(CancellationToken cancellationToken = default)
{
var result = new List<AgentSummary>();
await using var connection = await OpenAsync(cancellationToken);
var command = connection.CreateCommand();
command.CommandText = """
SELECT node_id, name, status, agent_version, last_seen_at
FROM agents ORDER BY name;
""";
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
result.Add(new AgentSummary(
reader.GetString(0),
reader.GetString(1),
reader.GetString(2),
reader.GetString(3),
reader.IsDBNull(4) ? null : DateTimeOffset.Parse(reader.GetString(4))));
}
return result;
}
public async Task<LinkMutation> CreateLinkMutationAsync(
LinkPolicyCreateRequest request,
string actor,
CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken);
await using var transaction = (SqliteTransaction)await connection.BeginTransactionAsync(cancellationToken);
var cached = await ReadIdempotentAsync<LinkPolicy>(
connection,
transaction,
$"link-create:{actor}:{request.IdempotencyKey}",
Fingerprint(request, SmmJsonContext.Default.LinkPolicyCreateRequest),
SmmJsonContext.Default.LinkPolicy,
cancellationToken);
if (cached is not null)
{
var current = await ReadLinkAsync(connection, transaction, cached.Id, cancellationToken) ?? cached;
await transaction.CommitAsync(cancellationToken);
return new LinkMutation(current, true);
}
var nodes = connection.CreateCommand();
nodes.Transaction = transaction;
nodes.CommandText = """
SELECT COUNT(*) FROM agents
WHERE node_id IN ($source, $target) AND status != 'Revoked';
""";
nodes.Parameters.AddWithValue("$source", request.SourceNodeId);
nodes.Parameters.AddWithValue("$target", request.TargetNodeId);
if (Convert.ToInt32(await nodes.ExecuteScalarAsync(cancellationToken)) != 2)
{
throw new InvalidOperationException("Source or target agent is not registered.");
}
var now = DateTimeOffset.UtcNow;
DateTimeOffset? expiresAt = request.TtlMinutes == 0
? null
: now.AddMinutes(request.TtlMinutes);
var versionCommand = connection.CreateCommand();
versionCommand.Transaction = transaction;
versionCommand.CommandText = "SELECT COALESCE(MAX(version), 0) + 1 FROM links;";
var version = Convert.ToInt64(await versionCommand.ExecuteScalarAsync(cancellationToken));
var link = new LinkPolicy(
Guid.NewGuid().ToString("N"),
request.SourceNodeId,
request.TargetNodeId,
request.Protocol,
request.Port,
request.TtlMinutes,
request.Reason,
"Active",
"Connecting",
version,
now,
expiresAt,
now,
null);
var insert = connection.CreateCommand();
insert.Transaction = transaction;
insert.CommandText = """
INSERT INTO links(
id, source_node_id, target_node_id, protocol, port, ttl_minutes, reason,
desired_state, actual_state, version, created_at, expires_at, updated_at, last_error)
VALUES (
$id, $source, $target, $protocol, $port, $ttl, $reason,
$desired, $actual, $version, $created, $expires, $updated, NULL);
""";
AddLinkParameters(insert, link);
await insert.ExecuteNonQueryAsync(cancellationToken);
await WriteIdempotentAsync(
connection,
transaction,
$"link-create:{actor}:{request.IdempotencyKey}",
Fingerprint(request, SmmJsonContext.Default.LinkPolicyCreateRequest),
link,
SmmJsonContext.Default.LinkPolicy,
cancellationToken);
await WriteAuditAsync(
connection,
transaction,
actor,
"link.connect.requested",
link.Id,
JsonSerializer.Serialize(link, SmmJsonContext.Default.LinkPolicy),
cancellationToken);
await transaction.CommitAsync(cancellationToken);
return new LinkMutation(link, false);
}
public async Task<LinkMutation?> BeginDisableLinkMutationAsync(
string id,
LinkPolicyDisableRequest request,
string actor,
CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken);
await using var transaction = (SqliteTransaction)await connection.BeginTransactionAsync(cancellationToken);
var cached = await ReadIdempotentAsync<LinkPolicy>(
connection,
transaction,
$"link-disable:{actor}:{id}:{request.IdempotencyKey}",
Fingerprint(request, SmmJsonContext.Default.LinkPolicyDisableRequest),
SmmJsonContext.Default.LinkPolicy,
cancellationToken);
if (cached is not null)
{
var current = await ReadLinkAsync(connection, transaction, cached.Id, cancellationToken) ?? cached;
await transaction.CommitAsync(cancellationToken);
return new LinkMutation(current, true);
}
var existing = await ReadLinkAsync(connection, transaction, id, cancellationToken);
if (existing is null)
{
await transaction.RollbackAsync(cancellationToken);
return null;
}
var now = DateTimeOffset.UtcNow;
var link = existing with
{
DesiredState = "Disabled",
ActualState = "Disconnecting",
Version = existing.Version + 1,
UpdatedAt = now,
LastError = null
};
var update = connection.CreateCommand();
update.Transaction = transaction;
update.CommandText = """
UPDATE links SET
desired_state = $desired,
actual_state = $actual,
version = $version,
updated_at = $updated,
last_error = NULL
WHERE id = $id;
""";
AddLinkParameters(update, link);
await update.ExecuteNonQueryAsync(cancellationToken);
await WriteIdempotentAsync(
connection,
transaction,
$"link-disable:{actor}:{id}:{request.IdempotencyKey}",
Fingerprint(request, SmmJsonContext.Default.LinkPolicyDisableRequest),
link,
SmmJsonContext.Default.LinkPolicy,
cancellationToken);
await WriteAuditAsync(
connection, transaction, actor, "link.disconnect.requested", id, "{}", cancellationToken);
await transaction.CommitAsync(cancellationToken);
return new LinkMutation(link, false);
}
public async Task<LinkPolicy?> SetLinkActualStateAsync(
string id,
string state,
string? error,
string actor,
CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken);
await using var transaction = (SqliteTransaction)await connection.BeginTransactionAsync(cancellationToken);
var update = connection.CreateCommand();
update.Transaction = transaction;
update.CommandText = """
UPDATE links SET actual_state = $state, last_error = $error, updated_at = $now
WHERE id = $id;
""";
update.Parameters.AddWithValue("$state", state);
update.Parameters.AddWithValue("$error", (object?)error ?? DBNull.Value);
update.Parameters.AddWithValue("$now", DateTimeOffset.UtcNow.ToString("O"));
update.Parameters.AddWithValue("$id", id);
if (await update.ExecuteNonQueryAsync(cancellationToken) != 1)
{
await transaction.RollbackAsync(cancellationToken);
return null;
}
var link = await ReadLinkAsync(connection, transaction, id, cancellationToken);
await WriteAuditAsync(
connection,
transaction,
actor,
$"link.state.{state.ToLowerInvariant()}",
id,
error is null
? "{}"
: JsonSerializer.Serialize(new ControlError(error), SmmJsonContext.Default.ControlError),
cancellationToken);
await transaction.CommitAsync(cancellationToken);
return link;
}
public async Task<IReadOnlyList<LinkPolicy>> ListLinksAsync(CancellationToken cancellationToken = default)
{
var result = new List<LinkPolicy>();
await using var connection = await OpenAsync(cancellationToken);
var command = connection.CreateCommand();
command.CommandText = "SELECT * FROM links ORDER BY created_at DESC;";
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
result.Add(ReadLink(reader));
}
return result;
}
private static void AddLinkParameters(SqliteCommand command, LinkPolicy link)
{
command.Parameters.AddWithValue("$id", link.Id);
command.Parameters.AddWithValue("$source", link.SourceNodeId);
command.Parameters.AddWithValue("$target", link.TargetNodeId);
command.Parameters.AddWithValue("$protocol", link.Protocol);
command.Parameters.AddWithValue("$port", link.Port);
command.Parameters.AddWithValue("$ttl", link.TtlMinutes);
command.Parameters.AddWithValue("$reason", link.Reason);
command.Parameters.AddWithValue("$desired", link.DesiredState);
command.Parameters.AddWithValue("$actual", link.ActualState);
command.Parameters.AddWithValue("$version", link.Version);
command.Parameters.AddWithValue("$created", link.CreatedAt.ToString("O"));
command.Parameters.AddWithValue(
"$expires",
link.ExpiresAt is null ? DBNull.Value : link.ExpiresAt.Value.ToString("O"));
command.Parameters.AddWithValue("$updated", link.UpdatedAt.ToString("O"));
}
private static async Task<LinkPolicy?> ReadLinkAsync(
SqliteConnection connection,
SqliteTransaction transaction,
string id,
CancellationToken cancellationToken)
{
var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = "SELECT * FROM links WHERE id = $id;";
command.Parameters.AddWithValue("$id", id);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
return await reader.ReadAsync(cancellationToken) ? ReadLink(reader) : null;
}
private static LinkPolicy ReadLink(SqliteDataReader reader)
=> new(
reader.GetString(0),
reader.GetString(1),
reader.GetString(2),
reader.GetString(3),
reader.GetInt32(4),
reader.GetInt32(5),
reader.GetString(6),
reader.GetString(7),
reader.GetString(8),
reader.GetInt64(9),
DateTimeOffset.Parse(reader.GetString(10)),
reader.IsDBNull(11) ? null : DateTimeOffset.Parse(reader.GetString(11)),
DateTimeOffset.Parse(reader.GetString(12)),
reader.IsDBNull(13) ? null : reader.GetString(13));
private async Task<SqliteConnection> OpenAsync(CancellationToken cancellationToken)
{
var connection = new SqliteConnection(_connectionString);
@ -352,3 +771,7 @@ public sealed class IdempotencyConflictException : Exception
{
}
}
public sealed record ControlIdentity(string Id, string Role);
public sealed record LinkMutation(LinkPolicy Link, bool IsReplay);

View file

@ -0,0 +1,69 @@
using System.Diagnostics;
using Microsoft.Extensions.Options;
using ServerMonitorManager.Core;
namespace ServerMonitorManager.Control;
public interface ILinkPolicyApplier
{
Task ApplyConnectAsync(LinkPolicy link, CancellationToken cancellationToken);
Task ApplyDisconnectAsync(LinkPolicy link, CancellationToken cancellationToken);
}
public sealed class LinkPolicyApplier(IOptions<ControlOptions> options) : ILinkPolicyApplier
{
public Task ApplyConnectAsync(LinkPolicy link, CancellationToken cancellationToken)
=> RunAsync(
[
"link-connect",
link.SourceNodeId,
link.TargetNodeId,
link.Protocol,
link.Port.ToString(System.Globalization.CultureInfo.InvariantCulture),
link.TtlMinutes.ToString(System.Globalization.CultureInfo.InvariantCulture)
],
cancellationToken);
public Task ApplyDisconnectAsync(LinkPolicy link, CancellationToken cancellationToken)
=> RunAsync(
[
"link-disconnect",
link.SourceNodeId,
link.TargetNodeId,
link.Protocol,
link.Port.ToString(System.Globalization.CultureInfo.InvariantCulture)
],
cancellationToken);
private async Task RunAsync(IReadOnlyList<string> arguments, CancellationToken cancellationToken)
{
var startInfo = new ProcessStartInfo
{
FileName = "/usr/bin/sudo",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
startInfo.ArgumentList.Add("-n");
startInfo.ArgumentList.Add(options.Value.HubHelperPath);
foreach (var argument in arguments)
{
startInfo.ArgumentList.Add(argument);
}
using var process = Process.Start(startInfo)
?? throw new InvalidOperationException("Could not start the Hub policy helper.");
var output = process.StandardOutput.ReadToEndAsync(cancellationToken);
var error = process.StandardError.ReadToEndAsync(cancellationToken);
await process.WaitForExitAsync(cancellationToken);
if (process.ExitCode != 0)
{
var message = (await error).Trim();
throw new InvalidOperationException(string.IsNullOrWhiteSpace(message)
? $"Hub policy helper exited with code {process.ExitCode}."
: message);
}
_ = await output;
}
}

View file

@ -0,0 +1,82 @@
using System.Text.Json;
using ServerMonitorManager.Core;
namespace ServerMonitorManager.Control;
public sealed class LinkService(
ControlStore store,
ILinkPolicyApplier applier,
ControlEventBroker events)
{
public async Task<LinkPolicy> CreateAsync(
LinkPolicyCreateRequest request,
string actor,
CancellationToken cancellationToken)
{
var mutation = await store.CreateLinkMutationAsync(request, actor, cancellationToken);
var link = mutation.Link;
if (mutation.IsReplay)
{
return link;
}
Publish("link.connecting", link);
try
{
await applier.ApplyConnectAsync(link, cancellationToken);
link = await store.SetLinkActualStateAsync(link.Id, "Active", null, actor, cancellationToken)
?? throw new InvalidOperationException("The persisted Link disappeared.");
Publish("link.active", link);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
link = await store.SetLinkActualStateAsync(
link.Id, "Failed", CompactError(exception), actor, cancellationToken)
?? link;
Publish("link.failed", link);
}
return link;
}
public async Task<LinkPolicy?> DisableAsync(
string id,
LinkPolicyDisableRequest request,
string actor,
CancellationToken cancellationToken)
{
var mutation = await store.BeginDisableLinkMutationAsync(id, request, actor, cancellationToken);
if (mutation is null)
{
return null;
}
var link = mutation.Link;
if (mutation.IsReplay)
{
return link;
}
Publish("link.disconnecting", link);
try
{
await applier.ApplyDisconnectAsync(link, cancellationToken);
link = await store.SetLinkActualStateAsync(id, "Disabled", null, actor, cancellationToken) ?? link;
Publish("link.disabled", link);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
link = await store.SetLinkActualStateAsync(
id, "Partial", CompactError(exception), actor, cancellationToken)
?? link;
Publish("link.partial", link);
}
return link;
}
private void Publish(string type, LinkPolicy link)
=> events.Publish(
type,
link.Id,
JsonSerializer.Serialize(link, SmmJsonContext.Default.LinkPolicy));
private static string CompactError(Exception exception)
=> exception.Message.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)
.FirstOrDefault() ?? "Policy application failed.";
}

View file

@ -1,10 +1,12 @@
using System.Security.Claims;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text.Json;
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.Authentication.Certificate;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Server.Kestrel.Https;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Options;
using ServerMonitorManager.Control;
using ServerMonitorManager.Core;
@ -37,6 +39,9 @@ builder.Services.AddOptions<ControlOptions>()
.ValidateOnStart();
builder.Services.AddSingleton<ControlStore>();
builder.Services.AddSingleton<CertificateAuthority>();
builder.Services.AddSingleton<ControlEventBroker>();
builder.Services.AddSingleton<ILinkPolicyApplier, LinkPolicyApplier>();
builder.Services.AddSingleton<LinkService>();
builder.Services.AddAuthentication(CertificateAuthenticationDefaults.AuthenticationScheme)
.AddCertificate(options =>
{
@ -49,14 +54,18 @@ builder.Services.AddAuthentication(CertificateAuthenticationDefaults.Authenticat
OnCertificateValidated = async context =>
{
var store = context.HttpContext.RequestServices.GetRequiredService<ControlStore>();
if (!await store.IsCertificateActiveAsync(context.ClientCertificate.Thumbprint))
var identity = await store.ResolveIdentityAsync(context.ClientCertificate.Thumbprint);
if (identity is null)
{
context.Fail("The agent certificate is unknown, expired, or revoked.");
return;
}
context.Principal = new ClaimsPrincipal(new ClaimsIdentity(
[new Claim(ClaimTypes.NameIdentifier, context.ClientCertificate.Thumbprint)],
[
new Claim(ClaimTypes.NameIdentifier, identity.Id),
new Claim(ClaimTypes.Role, identity.Role)
],
context.Scheme.Name));
context.Success();
}
@ -69,7 +78,11 @@ builder.Services.AddOptions<CertificateAuthenticationOptions>(
options.ChainTrustValidationMode = X509ChainTrustMode.CustomRootTrust;
options.CustomTrustStore.Add(authority.PublicCertificate);
});
builder.Services.AddAuthorization();
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("Agent", policy => policy.RequireRole("Agent"));
options.AddPolicy("Operator", policy => policy.RequireRole("Operator"));
});
builder.WebHost.ConfigureKestrel(options =>
{
options.Limits.MaxRequestBodySize = 64 * 1024;
@ -99,6 +112,18 @@ if (args is ["token-create", var nodeId])
return 0;
}
if (args is ["device-token-create", var deviceId])
{
if (!NodeIdValidator.IsValid(deviceId))
{
Console.Error.WriteLine("Device id must contain 1-63 lowercase letters, digits, or hyphens.");
return 2;
}
Console.WriteLine(await store.CreateDeviceEnrollmentTokenAsync(deviceId, TimeSpan.FromMinutes(10)));
return 0;
}
app.MapHealthChecks("/healthz").AllowAnonymous();
app.MapPost("/api/v1/enroll", async (
@ -147,7 +172,49 @@ app.MapPost("/api/v1/enroll", async (
: Results.Ok(response);
}).AllowAnonymous().RequireRateLimiting("enrollment");
var agents = app.MapGroup("/api/v1/agents").RequireAuthorization();
app.MapPost("/api/v1/device-enroll", async (
DeviceEnrollmentRequest request,
ControlStore controlStore,
CertificateAuthority authority,
CancellationToken cancellationToken) =>
{
if (!NodeIdValidator.IsValid(request.DeviceId)
|| string.IsNullOrWhiteSpace(request.Token)
|| string.IsNullOrWhiteSpace(request.CertificateSigningRequestPem)
|| !IdempotencyKeyValidator.IsValid(request.IdempotencyKey))
{
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["request"] = ["Invalid device enrollment request."]
});
}
try
{
var response = await controlStore.EnrollDeviceAsync(
request,
() => authority.IssueClientCertificate(request.DeviceId, request.CertificateSigningRequestPem),
cancellationToken);
return response is null ? Results.Unauthorized() : Results.Ok(response);
}
catch (IdempotencyConflictException)
{
return Results.Conflict(new ProblemDetails
{
Title = "Idempotency key conflict",
Status = StatusCodes.Status409Conflict
});
}
catch (Exception exception) when (exception is CryptographicException or InvalidOperationException)
{
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["certificateSigningRequestPem"] = ["Invalid certificate signing request."]
});
}
}).AllowAnonymous().RequireRateLimiting("enrollment");
var agents = app.MapGroup("/api/v1/agents").RequireAuthorization("Agent");
agents.MapPost("/heartbeat", async (
AgentHeartbeat heartbeat,
HttpContext context,
@ -186,6 +253,11 @@ agents.MapPost("/heartbeat", async (
heartbeat,
options.Value.HeartbeatSeconds,
cancellationToken);
var broker = context.RequestServices.GetRequiredService<ControlEventBroker>();
broker.Publish(
"agent.heartbeat",
heartbeat.NodeId,
JsonSerializer.Serialize(heartbeat, SmmJsonContext.Default.AgentHeartbeat));
return Results.Ok(response);
}
catch (IdempotencyConflictException)
@ -198,6 +270,86 @@ agents.MapPost("/heartbeat", async (
}
});
var control = app.MapGroup("/api/v1/control").RequireAuthorization("Operator");
control.MapGet("/agents", async (ControlStore controlStore, CancellationToken cancellationToken) =>
Results.Ok((await controlStore.ListAgentsAsync(cancellationToken)).ToArray()));
control.MapGet("/links", async (ControlStore controlStore, CancellationToken cancellationToken) =>
Results.Ok((await controlStore.ListLinksAsync(cancellationToken)).ToArray()));
control.MapPost("/links", async (
LinkPolicyCreateRequest request,
HttpContext context,
LinkService linkService,
CancellationToken cancellationToken) =>
{
if (!LinkPolicyValidator.IsValid(request))
{
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["link"] = ["Invalid source, target, protocol, port, TTL, reason, or idempotency key."]
});
}
var actor = context.User.FindFirstValue(ClaimTypes.NameIdentifier)!;
try
{
var link = await linkService.CreateAsync(request, actor, cancellationToken);
return Results.Created($"/api/v1/control/links/{link.Id}", link);
}
catch (IdempotencyConflictException)
{
return Results.Conflict(new ProblemDetails { Title = "Idempotency key conflict" });
}
catch (SqliteException exception) when (exception.SqliteErrorCode == 19)
{
return Results.Conflict(new ProblemDetails { Title = "An active Link already exists." });
}
catch (InvalidOperationException exception)
{
return Results.BadRequest(new ProblemDetails { Title = exception.Message });
}
});
control.MapPost("/links/{id}/disable", async (
string id,
LinkPolicyDisableRequest request,
HttpContext context,
LinkService linkService,
CancellationToken cancellationToken) =>
{
if (id.Length != 32 || !Guid.TryParseExact(id, "N", out _)
|| !IdempotencyKeyValidator.IsValid(request.IdempotencyKey))
{
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["link"] = ["Invalid Link id or idempotency key."]
});
}
var actor = context.User.FindFirstValue(ClaimTypes.NameIdentifier)!;
try
{
var link = await linkService.DisableAsync(id, request, actor, cancellationToken);
return link is null ? Results.NotFound() : Results.Ok(link);
}
catch (IdempotencyConflictException)
{
return Results.Conflict(new ProblemDetails { Title = "Idempotency key conflict" });
}
});
control.MapGet("/events", async (HttpContext context, ControlEventBroker broker) =>
{
context.Response.ContentType = "application/x-ndjson";
context.Response.Headers.CacheControl = "no-store";
using var subscription = broker.Subscribe();
await foreach (var controlEvent in subscription.Reader.ReadAllAsync(context.RequestAborted))
{
await JsonSerializer.SerializeAsync(
context.Response.Body,
controlEvent,
SmmJsonContext.Default.ControlEvent,
context.RequestAborted);
await context.Response.WriteAsync("\n", context.RequestAborted);
await context.Response.Body.FlushAsync(context.RequestAborted);
}
});
await app.RunAsync();
return 0;
@ -215,3 +367,16 @@ internal static class IdempotencyKeyValidator
public static bool IsValid(string value)
=> Guid.TryParse(value, out _);
}
internal static class LinkPolicyValidator
{
public static bool IsValid(LinkPolicyCreateRequest request)
=> NodeIdValidator.IsValid(request.SourceNodeId)
&& NodeIdValidator.IsValid(request.TargetNodeId)
&& request.SourceNodeId != request.TargetNodeId
&& request.Protocol is "tcp" or "udp"
&& request.Port is >= 1 and <= 65535
&& request.TtlMinutes is >= 0 and <= 525600
&& request.Reason.Length <= 256
&& IdempotencyKeyValidator.IsValid(request.IdempotencyKey);
}

View file

@ -3,7 +3,8 @@
"DatabasePath": "/var/lib/ochenstarik-server-monitor-manager/control.db",
"CertificateAuthorityPath": "/etc/ochenstarik-server-monitor-manager/control-ca.pfx",
"CertificateAuthorityPassword": null,
"HeartbeatSeconds": 30
"HeartbeatSeconds": 30,
"HubHelperPath": "/usr/local/libexec/ochenstarik-smm-policy-apply"
},
"Logging": {
"LogLevel": {

View file

@ -37,3 +37,51 @@ public sealed record AgentSummary(
string Status,
string AgentVersion,
DateTimeOffset? LastSeenAt);
public sealed record DeviceEnrollmentRequest(
string DeviceId,
string Token,
string CertificateSigningRequestPem,
string IdempotencyKey);
public sealed record DeviceEnrollmentResponse(
string DeviceId,
string CertificatePem,
string CertificateAuthorityPem,
DateTimeOffset ExpiresAt);
public sealed record LinkPolicyCreateRequest(
string SourceNodeId,
string TargetNodeId,
string Protocol,
int Port,
int TtlMinutes,
string Reason,
string IdempotencyKey);
public sealed record LinkPolicyDisableRequest(string IdempotencyKey);
public sealed record LinkPolicy(
string Id,
string SourceNodeId,
string TargetNodeId,
string Protocol,
int Port,
int TtlMinutes,
string Reason,
string DesiredState,
string ActualState,
long Version,
DateTimeOffset CreatedAt,
DateTimeOffset? ExpiresAt,
DateTimeOffset UpdatedAt,
string? LastError);
public sealed record ControlEvent(
long Sequence,
string Type,
string Subject,
DateTimeOffset RecordedAt,
string PayloadJson);
public sealed record ControlError(string Error);

View file

@ -7,4 +7,12 @@ namespace ServerMonitorManager.Core;
[JsonSerializable(typeof(AgentHeartbeat))]
[JsonSerializable(typeof(AgentHeartbeatResponse))]
[JsonSerializable(typeof(AgentSummary[]))]
[JsonSerializable(typeof(DeviceEnrollmentRequest))]
[JsonSerializable(typeof(DeviceEnrollmentResponse))]
[JsonSerializable(typeof(LinkPolicyCreateRequest))]
[JsonSerializable(typeof(LinkPolicyDisableRequest))]
[JsonSerializable(typeof(LinkPolicy))]
[JsonSerializable(typeof(LinkPolicy[]))]
[JsonSerializable(typeof(ControlEvent))]
[JsonSerializable(typeof(ControlError))]
public sealed partial class SmmJsonContext : JsonSerializerContext;

View file

@ -0,0 +1,318 @@
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using ServerMonitorManager.Core;
using Windows.Storage;
namespace ServerMonitorManager_Desktop;
public sealed record ControlEnrollmentPreview(
string DeviceId,
Uri ControlUrl,
string Token,
byte[] CertificateAuthority,
string Fingerprint);
public sealed partial class ControlClientService
{
private const string FolderName = "control";
private const string ProtectedCertificateName = "device.pfx.dpapi";
private const string CertificateAuthorityName = "control-ca.crt";
private const string ConfigurationName = "control.conf";
public bool IsConfigured
{
get
{
var folder = Path.Combine(ApplicationData.Current.LocalFolder.Path, FolderName);
return File.Exists(Path.Combine(folder, ConfigurationName))
&& File.Exists(Path.Combine(folder, ProtectedCertificateName))
&& File.Exists(Path.Combine(folder, CertificateAuthorityName));
}
}
public ControlEnrollmentPreview ParseEnrollmentCode(string code)
{
code = code.Trim().Replace("\r", string.Empty, StringComparison.Ordinal);
if (!code.StartsWith("SMMDEV1-", StringComparison.Ordinal))
{
throw new InvalidOperationException("Ожидается код формата SMMDEV1-...");
}
var encoded = code[8..].Replace('-', '+').Replace('_', '/');
encoded += (encoded.Length % 4) switch
{
2 => "==",
3 => "=",
0 => string.Empty,
_ => throw new InvalidOperationException("Некорректный код устройства.")
};
string payload;
try
{
payload = Encoding.UTF8.GetString(Convert.FromBase64String(encoded));
}
catch (FormatException)
{
throw new InvalidOperationException("Не удалось декодировать код устройства.");
}
var values = payload.Split('\n', StringSplitOptions.RemoveEmptyEntries)
.Select(line => line.Split('=', 2))
.Where(parts => parts.Length == 2)
.ToDictionary(parts => parts[0], parts => parts[1], StringComparer.Ordinal);
if (values.GetValueOrDefault("VERSION") != "1"
|| !DeviceIdRegex().IsMatch(values.GetValueOrDefault("DEVICE", string.Empty))
|| !TokenRegex().IsMatch(values.GetValueOrDefault("TOKEN", string.Empty))
|| !Uri.TryCreate(values.GetValueOrDefault("URL"), UriKind.Absolute, out var controlUrl)
|| controlUrl.Scheme != Uri.UriSchemeHttps)
{
throw new InvalidOperationException("Поля SMMDEV1 не прошли проверку.");
}
byte[] certificateAuthority;
try
{
certificateAuthority = Convert.FromBase64String(values["CA"]);
}
catch (Exception exception) when (exception is FormatException or KeyNotFoundException)
{
throw new InvalidOperationException("В коде отсутствует корректный Control CA.");
}
using var certificate = X509CertificateLoader.LoadCertificate(certificateAuthority);
var now = DateTimeOffset.UtcNow;
if (now < certificate.NotBefore.ToUniversalTime() || now >= certificate.NotAfter.ToUniversalTime())
{
throw new InvalidOperationException("Сертификат Control CA сейчас недействителен.");
}
var fingerprint = Convert.ToHexString(SHA256.HashData(certificate.RawData));
fingerprint = string.Join(':', Enumerable.Range(0, fingerprint.Length / 2)
.Select(index => fingerprint.Substring(index * 2, 2)));
return new ControlEnrollmentPreview(
values["DEVICE"], controlUrl, values["TOKEN"], certificateAuthority, fingerprint);
}
public async Task EnrollAsync(
ControlEnrollmentPreview preview,
CancellationToken cancellationToken = default)
{
using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var certificateRequest = new CertificateRequest(
$"CN={preview.DeviceId}", key, HashAlgorithmName.SHA256);
var request = new DeviceEnrollmentRequest(
preview.DeviceId,
preview.Token,
certificateRequest.CreateSigningRequestPem(),
Guid.NewGuid().ToString());
using var client = CreateHttpClient(preview.ControlUrl, preview.CertificateAuthority, null);
using var response = await client.PostAsJsonAsync(
"api/v1/device-enroll",
request,
SmmJsonContext.Default.DeviceEnrollmentRequest,
cancellationToken);
response.EnsureSuccessStatusCode();
var enrollment = await response.Content.ReadFromJsonAsync(
SmmJsonContext.Default.DeviceEnrollmentResponse,
cancellationToken) ?? throw new InvalidOperationException("Control Hub вернул пустой ответ регистрации.");
using var certificate = X509Certificate2.CreateFromPem(
enrollment.CertificatePem,
key.ExportPkcs8PrivateKeyPem());
var pfx = certificate.Export(X509ContentType.Pfx);
try
{
var protectedPfx = ProtectedData.Protect(pfx, null, DataProtectionScope.CurrentUser);
var folder = await ApplicationData.Current.LocalFolder.CreateFolderAsync(
FolderName, CreationCollisionOption.OpenIfExists);
await File.WriteAllBytesAsync(
Path.Combine(folder.Path, ProtectedCertificateName), protectedPfx, cancellationToken);
await File.WriteAllBytesAsync(
Path.Combine(folder.Path, CertificateAuthorityName), preview.CertificateAuthority, cancellationToken);
await File.WriteAllLinesAsync(
Path.Combine(folder.Path, ConfigurationName),
[preview.ControlUrl.AbsoluteUri, preview.DeviceId],
cancellationToken);
}
finally
{
CryptographicOperations.ZeroMemory(pfx);
}
}
public async Task ListenAsync(
Func<ControlEvent, Task> onEvent,
CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested)
{
try
{
using var session = await CreateAuthenticatedSessionAsync(cancellationToken);
if (session is null)
{
return;
}
using var request = new HttpRequestMessage(HttpMethod.Get, "api/v1/control/events");
using var response = await session.Client.SendAsync(
request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
response.EnsureSuccessStatusCode();
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
using var reader = new StreamReader(stream, Encoding.UTF8);
while (!cancellationToken.IsCancellationRequested)
{
var line = await reader.ReadLineAsync(cancellationToken);
if (line is null)
{
break;
}
var controlEvent = JsonSerializer.Deserialize(line, SmmJsonContext.Default.ControlEvent);
if (controlEvent is not null)
{
await onEvent(controlEvent);
}
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
return;
}
catch
{
await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken);
}
}
}
public async Task<IReadOnlyList<AgentSummary>> GetAgentsAsync(CancellationToken cancellationToken)
{
using var session = await RequireAuthenticatedSessionAsync(cancellationToken);
return await session.Client.GetFromJsonAsync(
"api/v1/control/agents",
SmmJsonContext.Default.AgentSummaryArray,
cancellationToken) ?? [];
}
public async Task<IReadOnlyList<LinkPolicy>> GetLinksAsync(CancellationToken cancellationToken)
{
using var session = await RequireAuthenticatedSessionAsync(cancellationToken);
return await session.Client.GetFromJsonAsync(
"api/v1/control/links",
SmmJsonContext.Default.LinkPolicyArray,
cancellationToken) ?? [];
}
public async Task<LinkPolicy> CreateLinkAsync(
LinkPolicyCreateRequest request,
CancellationToken cancellationToken)
{
using var session = await RequireAuthenticatedSessionAsync(cancellationToken);
using var response = await session.Client.PostAsJsonAsync(
"api/v1/control/links",
request,
SmmJsonContext.Default.LinkPolicyCreateRequest,
cancellationToken);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync(
SmmJsonContext.Default.LinkPolicy,
cancellationToken) ?? throw new InvalidOperationException("Control Hub вернул пустой Link.");
}
public async Task<LinkPolicy> DisableLinkAsync(string id, CancellationToken cancellationToken)
{
using var session = await RequireAuthenticatedSessionAsync(cancellationToken);
using var response = await session.Client.PostAsJsonAsync(
$"api/v1/control/links/{id}/disable",
new LinkPolicyDisableRequest(Guid.NewGuid().ToString()),
SmmJsonContext.Default.LinkPolicyDisableRequest,
cancellationToken);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync(
SmmJsonContext.Default.LinkPolicy,
cancellationToken) ?? throw new InvalidOperationException("Control Hub вернул пустой Link.");
}
private static HttpClient CreateHttpClient(
Uri baseAddress,
byte[] rootBytes,
X509Certificate2? clientCertificate)
{
var handler = new HttpClientHandler();
if (clientCertificate is not null)
{
handler.ClientCertificates.Add(clientCertificate);
}
handler.ServerCertificateCustomValidationCallback = (_, certificate, _, errors) =>
{
if (certificate is null
|| errors.HasFlag(System.Net.Security.SslPolicyErrors.RemoteCertificateNameMismatch)
|| errors.HasFlag(System.Net.Security.SslPolicyErrors.RemoteCertificateNotAvailable))
{
return false;
}
using var trustedRoot = X509CertificateLoader.LoadCertificate(rootBytes);
using var chain = new X509Chain();
chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
chain.ChainPolicy.CustomTrustStore.Add(trustedRoot);
chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
chain.ChainPolicy.ApplicationPolicy.Add(new Oid("1.3.6.1.5.5.7.3.1"));
return chain.Build(new X509Certificate2(certificate));
};
return new HttpClient(handler) { BaseAddress = baseAddress };
}
private static async Task<AuthenticatedControlSession?> CreateAuthenticatedSessionAsync(
CancellationToken cancellationToken)
{
var folder = Path.Combine(ApplicationData.Current.LocalFolder.Path, FolderName);
var configurationPath = Path.Combine(folder, ConfigurationName);
var protectedPath = Path.Combine(folder, ProtectedCertificateName);
var caPath = Path.Combine(folder, CertificateAuthorityName);
if (!File.Exists(configurationPath) || !File.Exists(protectedPath) || !File.Exists(caPath))
{
return null;
}
var configuration = await File.ReadAllLinesAsync(configurationPath, cancellationToken);
if (configuration.Length < 1 || !Uri.TryCreate(configuration[0], UriKind.Absolute, out var controlUrl))
{
throw new InvalidOperationException("Сохранённая конфигурация Control Hub повреждена.");
}
var protectedPfx = await File.ReadAllBytesAsync(protectedPath, cancellationToken);
var pfx = ProtectedData.Unprotect(protectedPfx, null, DataProtectionScope.CurrentUser);
try
{
var certificate = X509CertificateLoader.LoadPkcs12(
pfx, password: null, X509KeyStorageFlags.EphemeralKeySet);
var ca = await File.ReadAllBytesAsync(caPath, cancellationToken);
return new AuthenticatedControlSession(
CreateHttpClient(controlUrl, ca, certificate), certificate);
}
finally
{
CryptographicOperations.ZeroMemory(pfx);
}
}
private static async Task<AuthenticatedControlSession> RequireAuthenticatedSessionAsync(
CancellationToken cancellationToken)
=> await CreateAuthenticatedSessionAsync(cancellationToken)
?? throw new InvalidOperationException("Сначала подключите Control Hub через код SMMDEV1.");
[GeneratedRegex("^[a-z0-9][a-z0-9-]{0,62}$", RegexOptions.CultureInvariant)]
private static partial Regex DeviceIdRegex();
[GeneratedRegex("^[A-Za-z0-9_-]{43}$", RegexOptions.CultureInvariant)]
private static partial Regex TokenRegex();
}
internal sealed class AuthenticatedControlSession(HttpClient client, X509Certificate2 certificate) : IDisposable
{
public HttpClient Client { get; } = client;
public void Dispose()
{
Client.Dispose();
certificate.Dispose();
}
}

View file

@ -74,6 +74,11 @@
Click="SshKeyButton_Click"
Icon="Permissions"
Label="SSH-ключ" />
<AppBarButton
AutomationProperties.Name="Подключить Control Hub"
Click="ControlHubButton_Click"
Icon="Link"
Label="Control Hub" />
<AppBarButton
AutomationProperties.Name="Открыть прямой SSH-терминал"
Click="TerminalButton_Click"

View file

@ -4,6 +4,7 @@ using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Automation;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Media;
using ServerMonitorManager.Core;
using Windows.ApplicationModel.DataTransfer;
using Windows.Foundation;
@ -14,15 +15,19 @@ public sealed partial class MainPage : Page
private readonly ServerStorage _storage = new();
private readonly SshMonitorService _ssh = new();
private readonly MetricsHistoryStorage _historyStorage = new();
private readonly ControlClientService _control = new();
private readonly List<MetricSampleData> _history = [];
private readonly DispatcherTimer _refreshTimer = new() { Interval = TimeSpan.FromSeconds(30) };
private readonly SemaphoreSlim _refreshLock = new(1, 1);
private readonly CancellationTokenSource _controlCancellation = new();
private bool _loaded;
private bool _controlListening;
public MainPage()
{
InitializeComponent();
Loaded += MainPage_Loaded;
Unloaded += (_, _) => _controlCancellation.Cancel();
_refreshTimer.Tick += async (_, _) => await RefreshAllAsync();
MainNavigation.SelectedItem = MainNavigation.MenuItems[0];
}
@ -56,6 +61,7 @@ public sealed partial class MainPage : Page
{
await RefreshAllAsync();
}
StartControlEvents();
}
private async void SshKeyButton_Click(object sender, RoutedEventArgs e)
@ -161,6 +167,137 @@ public sealed partial class MainPage : Page
}
}
private async void ControlHubButton_Click(object sender, RoutedEventArgs e)
{
var codeBox = new TextBox
{
Header = "Код устройства SMMDEV1",
PlaceholderText = "SMMDEV1-...",
AcceptsReturn = true,
TextWrapping = TextWrapping.Wrap,
MinWidth = 360
};
AutomationProperties.SetName(codeBox, "Одноразовый код Control Hub для Windows-устройства");
var codeDialog = new ContentDialog
{
XamlRoot = XamlRoot,
Title = "Подключение Control Hub",
Content = new StackPanel
{
Spacing = 12,
Children =
{
new TextBlock
{
Text = "На Hub выполните: sudo ochenstarik-server-monitor-manager.sh control-device-code windows-pc. Вставьте полученный одноразовый код.",
TextWrapping = TextWrapping.Wrap
},
codeBox
}
},
PrimaryButtonText = "Проверить",
CloseButtonText = "Отмена",
DefaultButton = ContentDialogButton.Primary
};
if (await codeDialog.ShowAsync() != ContentDialogResult.Primary)
{
return;
}
try
{
var preview = _control.ParseEnrollmentCode(codeBox.Text);
var fingerprintBox = new TextBox
{
Header = "SHA-256 fingerprint Control CA",
Text = preview.Fingerprint,
IsReadOnly = true,
TextWrapping = TextWrapping.Wrap
};
AutomationProperties.SetName(fingerprintBox, "SHA-256 fingerprint Control CA");
var confirmDialog = new ContentDialog
{
XamlRoot = XamlRoot,
Title = "Подтвердите Control Hub",
Content = new StackPanel
{
Spacing = 12,
Children =
{
new TextBlock
{
Text = $"Устройство: {preview.DeviceId}\nHub: {preview.ControlUrl}",
TextWrapping = TextWrapping.Wrap
},
fingerprintBox,
new TextBlock
{
Text = "Сравните fingerprint с показанным на Hub. После подтверждения приложение локально создаст отдельный operator key и защитит его через Windows DPAPI.",
TextWrapping = TextWrapping.Wrap
}
}
},
PrimaryButtonText = "Fingerprint совпадает",
CloseButtonText = "Отмена",
DefaultButton = ContentDialogButton.Close
};
if (await confirmDialog.ShowAsync() != ContentDialogResult.Primary)
{
return;
}
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
await _control.EnrollAsync(preview, timeout.Token);
ShowInfo(
"Control Hub подключён",
$"Operator identity {preview.DeviceId} сохранена через DPAPI. Поток событий запускается.",
InfoBarSeverity.Success);
StartControlEvents();
}
catch (Exception exception)
{
ShowInfo("Не удалось подключить Control Hub", CompactError(exception), InfoBarSeverity.Error);
}
}
private void StartControlEvents()
{
if (_controlListening || _controlCancellation.IsCancellationRequested)
{
return;
}
_controlListening = true;
_ = Task.Run(async () =>
{
try
{
await _control.ListenAsync(HandleControlEventAsync, _controlCancellation.Token);
}
finally
{
_controlListening = false;
}
});
}
private Task HandleControlEventAsync(ControlEvent controlEvent)
{
DispatcherQueue.TryEnqueue(() =>
{
if (controlEvent.Type.StartsWith("link.", StringComparison.Ordinal))
{
ShowInfo(
"Событие Control Hub",
$"{controlEvent.Subject}: {controlEvent.Type}",
controlEvent.Type is "link.failed" or "link.partial"
? InfoBarSeverity.Warning
: InfoBarSeverity.Informational);
_ = RefreshMeshAsync(showSuccess: false);
}
});
return Task.CompletedTask;
}
private async void AddServerButton_Click(object sender, RoutedEventArgs e)
{
try
@ -365,7 +502,7 @@ public sealed partial class MainPage : Page
WarningValueText.Text = warnings.ToString(CultureInfo.InvariantCulture);
WarningDetailText.Text = warnings == 0 ? "Нет предупреждений" : "Проверьте доступность и ресурсы";
HeaderStatusText.Text = $"SSH monitoring · {Servers.Count} сервер(а) · обновлено {DateTime.Now:HH:mm:ss}";
if (Servers.Any(server => server.IsHub))
if (Servers.Any(server => server.IsHub) || _control.IsConfigured)
{
await RefreshMeshAsync(showSuccess: false);
}
@ -531,6 +668,12 @@ public sealed partial class MainPage : Page
private async Task RefreshMeshAsync(bool showSuccess = true)
{
if (_control.IsConfigured)
{
await RefreshControlMeshAsync(showSuccess);
return;
}
var hub = FindHub();
if (hub is null)
{
@ -596,6 +739,57 @@ public sealed partial class MainPage : Page
}
}
private async Task RefreshControlMeshAsync(bool showSuccess)
{
try
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15));
var agents = await _control.GetAgentsAsync(timeout.Token);
var links = await _control.GetLinksAsync(timeout.Token);
var now = DateTimeOffset.UtcNow;
MeshNodes.Clear();
foreach (var agent in agents)
{
var age = agent.LastSeenAt is null
? int.MaxValue
: (int)Math.Clamp((now - agent.LastSeenAt.Value).TotalSeconds, 0, int.MaxValue);
MeshNodes.Add(new MeshNodeViewModel(
agent.NodeId,
"Control",
age <= 90 ? "online" : "offline",
age));
}
MeshLinks.Clear();
foreach (var link in links.Where(link =>
link.DesiredState != "Disabled" || link.ActualState == "Partial"))
{
MeshLinks.Add(new MeshLinkViewModel(
link.SourceNodeId,
link.TargetNodeId,
link.TargetNodeId,
link.Protocol,
link.Port,
link.ExpiresAt?.ToUnixTimeSeconds() ?? 0,
link.ActualState,
link.Version,
link.Id));
}
ActiveLinksValueText.Text = MeshLinks.Count.ToString(CultureInfo.InvariantCulture);
MeshStatusText.Text = $"Control · {MeshNodes.Count} узлов · {MeshLinks.Count} активных связей";
if (showSuccess)
{
ShowInfo("Control Hub обновлён", MeshStatusText.Text, InfoBarSeverity.Success);
}
}
catch (Exception exception)
{
MeshStatusText.Text = "Control Hub недоступен";
ShowInfo("Не удалось получить Control-состояние", CompactError(exception), InfoBarSeverity.Error);
}
}
private async void RefreshMeshButton_Click(object sender, RoutedEventArgs e)
=> await RefreshMeshAsync();
@ -607,8 +801,9 @@ public sealed partial class MainPage : Page
private async Task ChangeLinkAsync(bool enable)
{
var useControl = _control.IsConfigured;
var hub = FindHub();
if (hub is null)
if (!useControl && hub is null)
{
ShowInfo("Mesh Hub не выбран", "Сначала добавьте главный сервер с отметкой Mesh Hub.", InfoBarSeverity.Warning);
return;
@ -668,13 +863,49 @@ public sealed partial class MainPage : Page
try
{
if (useControl)
{
using var controlTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(20));
LinkPolicy link;
if (enable)
{
link = await _control.CreateLinkAsync(
new LinkPolicyCreateRequest(
source.Name,
target.Name,
protocol,
port,
ttlMinutes,
"Windows client",
Guid.NewGuid().ToString()),
controlTimeout.Token);
}
else
{
var selectedLink = (MeshLinkViewModel)MeshLinksList.SelectedItem;
if (string.IsNullOrWhiteSpace(selectedLink.Id))
{
throw new InvalidOperationException("У выбранного Link отсутствует Control id.");
}
link = await _control.DisableLinkAsync(selectedLink.Id, controlTimeout.Token);
}
await RefreshMeshAsync(showSuccess: false);
ShowInfo(
enable ? "Control Link создан" : "Control Link отключён",
$"{source.Name} → {target.Name} · {protocol.ToUpperInvariant()}/{port} · {link.ActualState} v{link.Version}",
link.ActualState is "Failed" or "Partial"
? InfoBarSeverity.Warning
: InfoBarSeverity.Success);
return;
}
var action = enable ? "connect" : "disconnect";
var policyArguments = enable
? $"{protocol} {port} {ttlMinutes}"
: $"{protocol} {port}";
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15));
var commandOutput = await _ssh.RunRestrictedCommandAsync(
hub.Profile,
hub!.Profile,
$"mesh {action} {source.Name} {target.Name} {policyArguments}",
timeout.Token);
var confirmation = commandOutput

View file

@ -27,7 +27,8 @@ public sealed class MeshLinkViewModel
int port,
long expiresUnix,
string state,
long version)
long version,
string? id = null)
{
Source = source;
Target = target;
@ -37,6 +38,7 @@ public sealed class MeshLinkViewModel
ExpiresUnix = expiresUnix;
State = state;
Version = version;
Id = id;
}
public string Source { get; set; }
@ -47,6 +49,7 @@ public sealed class MeshLinkViewModel
public long ExpiresUnix { get; set; }
public string State { get; set; }
public long Version { get; set; }
public string? Id { get; set; }
public string ExpirationText => ExpiresUnix == 0
? "вручную"
: $"до {DateTimeOffset.FromUnixTimeSeconds(ExpiresUnix).ToLocalTime():dd.MM HH:mm}";

View file

@ -53,6 +53,7 @@
<PropertyGroup> above to opt out.
-->
<ItemGroup>
<ProjectReference Include="..\ServerMonitorManager.Core\ServerMonitorManager.Core.csproj" />
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.28000.2270" />
<PackageReference Include="Microsoft.WindowsAppSDK" Version="2.2.0" />
<PackageReference Include="Microsoft.Windows.SDK.BuildTools.WinApp" Version="0.4.0" />

View file

@ -62,6 +62,112 @@ public sealed class ControlStoreTests : IAsyncDisposable
cancellationToken));
}
[Fact]
public async Task DeviceCertificateReceivesOperatorIdentity()
{
var cancellationToken = TestContext.Current.CancellationToken;
var store = CreateStore();
await store.InitializeAsync(cancellationToken);
var token = await store.CreateDeviceEnrollmentTokenAsync(
"windows-pc", TimeSpan.FromMinutes(10), cancellationToken);
var issued = new IssuedCertificate("certificate", "ca", "CC33", DateTimeOffset.UtcNow.AddYears(1));
var request = new DeviceEnrollmentRequest(
"windows-pc", token, "csr", Guid.NewGuid().ToString());
var first = await store.EnrollDeviceAsync(request, () => issued, cancellationToken);
var retry = await store.EnrollDeviceAsync(
request,
() => throw new InvalidOperationException("must use cache"),
cancellationToken);
var identity = await store.ResolveIdentityAsync("CC33", cancellationToken);
Assert.NotNull(first);
Assert.Equal(first, retry);
Assert.Equal(new ControlIdentity("windows-pc", "Operator"), identity);
Assert.False(await store.IsCertificateForNodeAsync("CC33", "home", cancellationToken));
}
[Fact]
public async Task LinkDesiredStateIsPersistedBeforeActualStateChanges()
{
var cancellationToken = TestContext.Current.CancellationToken;
var store = CreateStore();
await store.InitializeAsync(cancellationToken);
await EnrollAgentAsync(store, "ai-agent", "DD44", cancellationToken);
await EnrollAgentAsync(store, "home", "EE55", cancellationToken);
var request = new LinkPolicyCreateRequest(
"ai-agent", "home", "tcp", 22, 120, "development", Guid.NewGuid().ToString());
var connectingMutation = await store.CreateLinkMutationAsync(request, "windows-pc", cancellationToken);
var connecting = connectingMutation.Link;
var active = await store.SetLinkActualStateAsync(
connecting.Id, "Active", null, "windows-pc", cancellationToken);
var disconnectingMutation = await store.BeginDisableLinkMutationAsync(
connecting.Id,
new LinkPolicyDisableRequest(Guid.NewGuid().ToString()),
"windows-pc",
cancellationToken);
var disconnecting = disconnectingMutation?.Link;
var disabled = await store.SetLinkActualStateAsync(
connecting.Id, "Disabled", null, "windows-pc", cancellationToken);
var links = await store.ListLinksAsync(cancellationToken);
Assert.Equal("Active", connecting.DesiredState);
Assert.Equal("Connecting", connecting.ActualState);
Assert.Equal("Active", active?.ActualState);
Assert.Equal("Disabled", disconnecting?.DesiredState);
Assert.Equal("Disconnecting", disconnecting?.ActualState);
Assert.Equal("Disabled", disabled?.ActualState);
Assert.Single(links);
Assert.Equal(disabled, links[0]);
}
[Fact]
public async Task LinkServiceAppliesPersistedStatesAndPublishesEvents()
{
var cancellationToken = TestContext.Current.CancellationToken;
var store = CreateStore();
await store.InitializeAsync(cancellationToken);
await EnrollAgentAsync(store, "ai-agent", "FF66", cancellationToken);
await EnrollAgentAsync(store, "home", "0011", cancellationToken);
var broker = new ControlEventBroker();
using var subscription = broker.Subscribe();
var applier = new CheckingPolicyApplier(store);
var service = new LinkService(store, applier, broker);
var createRequest = new LinkPolicyCreateRequest(
"ai-agent", "home", "tcp", 22, 30, "test", Guid.NewGuid().ToString());
var active = await service.CreateAsync(
createRequest,
"windows-pc",
cancellationToken);
var createReplay = await service.CreateAsync(createRequest, "windows-pc", cancellationToken);
var disableRequest = new LinkPolicyDisableRequest(Guid.NewGuid().ToString());
var disabled = await service.DisableAsync(
active.Id,
disableRequest,
"windows-pc",
cancellationToken);
var disableReplay = await service.DisableAsync(
active.Id, disableRequest, "windows-pc", cancellationToken);
var eventTypes = new List<string>();
while (subscription.Reader.TryRead(out var controlEvent))
{
eventTypes.Add(controlEvent.Type);
}
Assert.Equal("Active", active.ActualState);
Assert.Equal(active, createReplay);
Assert.Equal("Disabled", disabled?.DesiredState);
Assert.Equal("Disabled", disabled?.ActualState);
Assert.Equal(disabled, disableReplay);
Assert.Equal(1, applier.ConnectCalls);
Assert.Equal(1, applier.DisconnectCalls);
Assert.Equal(
["link.connecting", "link.active", "link.disconnecting", "link.disabled"],
eventTypes);
}
public ValueTask DisposeAsync()
{
SqliteConnection.ClearAllPools();
@ -81,4 +187,44 @@ public sealed class ControlStoreTests : IAsyncDisposable
CertificateAuthorityPath = Path.Combine(_directory, "unused.pfx")
}));
}
private static async Task EnrollAgentAsync(
ControlStore store,
string nodeId,
string thumbprint,
CancellationToken cancellationToken)
{
var token = await store.CreateEnrollmentTokenAsync(nodeId, TimeSpan.FromMinutes(10), cancellationToken);
var issued = new IssuedCertificate(
"certificate", "ca", thumbprint, DateTimeOffset.UtcNow.AddYears(1));
var result = await store.EnrollAsync(
new EnrollmentRequest(nodeId, token, "csr", Guid.NewGuid().ToString()),
() => issued,
cancellationToken);
Assert.NotNull(result);
}
private sealed class CheckingPolicyApplier(ControlStore store) : ILinkPolicyApplier
{
public int ConnectCalls { get; private set; }
public int DisconnectCalls { get; private set; }
public async Task ApplyConnectAsync(LinkPolicy link, CancellationToken cancellationToken)
{
ConnectCalls++;
var persisted = Assert.Single(await store.ListLinksAsync(cancellationToken));
Assert.Equal(link.Id, persisted.Id);
Assert.Equal("Active", persisted.DesiredState);
Assert.Equal("Connecting", persisted.ActualState);
}
public async Task ApplyDisconnectAsync(LinkPolicy link, CancellationToken cancellationToken)
{
DisconnectCalls++;
var persisted = Assert.Single(await store.ListLinksAsync(cancellationToken));
Assert.Equal(link.Id, persisted.Id);
Assert.Equal("Disabled", persisted.DesiredState);
Assert.Equal("Disconnecting", persisted.ActualState);
}
}
}