Add Windows SSH monitoring MVP #1
21 changed files with 1584 additions and 20 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@
|
|||
- [x] срок действия не более 10 минут;
|
||||
- [x] атомарное погашение token;
|
||||
- [ ] отзыв и повторная регистрация Node;
|
||||
- [ ] подтверждение fingerprint Hub;
|
||||
- [x] подтверждение SHA-256 fingerprint Control CA Hub;
|
||||
- [x] защита desktop SSH-ключа через DPAPI.
|
||||
|
||||
## Этап 4 — управляемые Links
|
||||
|
|
@ -71,9 +71,9 @@
|
|||
## Этап 6 — постоянный control layer
|
||||
|
||||
- [x] самодостаточный single-file Linux agent для amd64/arm64;
|
||||
- [ ] SQLite inventory, policies, history и audit;
|
||||
- [x] SQLite inventory, policies, history и audit;
|
||||
- [x] исходящие mTLS agent sessions;
|
||||
- [ ] WebSocket/stream событий для desktop client;
|
||||
- [x] защищённый Hub event stream для desktop client;
|
||||
- [ ] ограниченный локальный буфер и downsampling;
|
||||
- [x] idempotency key и защита от replay;
|
||||
- [ ] тест нагрузки 50–100 Node на одном Hub.
|
||||
|
|
|
|||
|
|
@ -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 отделён от управления;
|
||||
|
|
|
|||
46
src/ServerMonitorManager.Control/ControlEventBroker.cs
Normal file
46
src/ServerMonitorManager.Control/ControlEventBroker.cs
Normal 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();
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
69
src/ServerMonitorManager.Control/LinkPolicyApplier.cs
Normal file
69
src/ServerMonitorManager.Control/LinkPolicyApplier.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
82
src/ServerMonitorManager.Control/LinkService.cs
Normal file
82
src/ServerMonitorManager.Control/LinkService.cs
Normal 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.";
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
318
src/ServerMonitorManager.Desktop/ControlClientService.cs
Normal file
318
src/ServerMonitorManager.Desktop/ControlClientService.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}";
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue