Add Windows SSH monitoring MVP #1
21 changed files with 1584 additions and 20 deletions
|
|
@ -53,6 +53,7 @@ Alpha-установка постоянного слоя после обычны
|
||||||
# Hub
|
# Hub
|
||||||
sudo ./ochenstarik-server-monitor-manager.sh install-control-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-code home
|
||||||
|
sudo ./ochenstarik-server-monitor-manager.sh control-device-code windows-pc
|
||||||
|
|
||||||
# соответствующий Node — вставить полученный SMMCTL1
|
# соответствующий Node — вставить полученный SMMCTL1
|
||||||
sudo ./ochenstarik-server-monitor-manager.sh install-control-agent
|
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 открытых входящих портов не создаёт.
|
Архив выбирается автоматически для 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 для разработчиков:
|
Проверка control layer для разработчиков:
|
||||||
|
|
||||||
```bash
|
```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
|
ExecStart=/usr/local/lib/ochenstarik-server-monitor-manager/control/ochenstarik-smm-control
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=10s
|
RestartSec=10s
|
||||||
NoNewPrivileges=true
|
|
||||||
PrivateTmp=true
|
PrivateTmp=true
|
||||||
PrivateDevices=true
|
PrivateDevices=true
|
||||||
ProtectSystem=strict
|
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 и отклоняется при попытке повторить тот же ключ с другим телом запроса.
|
Первый реализованный срез 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
|
## 7. Целевые ограничения MVP
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,10 +44,13 @@
|
||||||
|
|
||||||
- `install-control-hub` скачивает release-архив под amd64/arm64, проверяет SHA-256, создаёт локальный CA, HTTPS-сертификат Hub, SQLite-каталог и изолированный systemd service;
|
- `install-control-hub` скачивает release-архив под amd64/arm64, проверяет SHA-256, создаёт локальный CA, HTTPS-сертификат Hub, SQLite-каталог и изолированный systemd service;
|
||||||
- `control-code NAME` создаёт десятиминутный token и код `SMMCTL1`, содержащий URL Hub и только публичный CA;
|
- `control-code NAME` создаёт десятиминутный token и код `SMMCTL1`, содержащий URL Hub и только публичный CA;
|
||||||
|
- `control-device-code DEVICE` создаёт отдельный код `SMMDEV1` для operator identity Windows-клиента;
|
||||||
- `install-control-agent` проверяет CA, локально создаёт ключ и CSR, регистрирует сертификат и запускает исходящий mTLS Agent через systemd.
|
- `install-control-agent` проверяет CA, локально создаёт ключ и CSR, регистрирует сертификат и запускает исходящий mTLS Agent через systemd.
|
||||||
|
|
||||||
Приватный ключ Control CA не включается в `SMMCTL1`, а приватный ключ Agent не покидает Node. По умолчанию Control Hub слушает TCP `7443`.
|
Приватный ключ 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-hub
|
||||||
install-control-agent
|
install-control-agent
|
||||||
control-code NAME
|
control-code NAME
|
||||||
|
control-device-code DEVICE
|
||||||
status
|
status
|
||||||
update
|
update
|
||||||
rollback
|
rollback
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@
|
||||||
- [x] срок действия не более 10 минут;
|
- [x] срок действия не более 10 минут;
|
||||||
- [x] атомарное погашение token;
|
- [x] атомарное погашение token;
|
||||||
- [ ] отзыв и повторная регистрация Node;
|
- [ ] отзыв и повторная регистрация Node;
|
||||||
- [ ] подтверждение fingerprint Hub;
|
- [x] подтверждение SHA-256 fingerprint Control CA Hub;
|
||||||
- [x] защита desktop SSH-ключа через DPAPI.
|
- [x] защита desktop SSH-ключа через DPAPI.
|
||||||
|
|
||||||
## Этап 4 — управляемые Links
|
## Этап 4 — управляемые Links
|
||||||
|
|
@ -71,9 +71,9 @@
|
||||||
## Этап 6 — постоянный control layer
|
## Этап 6 — постоянный control layer
|
||||||
|
|
||||||
- [x] самодостаточный single-file Linux agent для amd64/arm64;
|
- [x] самодостаточный single-file Linux agent для amd64/arm64;
|
||||||
- [ ] SQLite inventory, policies, history и audit;
|
- [x] SQLite inventory, policies, history и audit;
|
||||||
- [x] исходящие mTLS agent sessions;
|
- [x] исходящие mTLS agent sessions;
|
||||||
- [ ] WebSocket/stream событий для desktop client;
|
- [x] защищённый Hub event stream для desktop client;
|
||||||
- [ ] ограниченный локальный буфер и downsampling;
|
- [ ] ограниченный локальный буфер и downsampling;
|
||||||
- [x] idempotency key и защита от replay;
|
- [x] idempotency key и защита от replay;
|
||||||
- [ ] тест нагрузки 50–100 Node на одном Hub.
|
- [ ] тест нагрузки 50–100 Node на одном Hub.
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,12 @@ Windows-клиент использует отдельный Ed25519-ключ т
|
||||||
|
|
||||||
До появления mTLS enrollment выполняется через отдельную ограниченную SSH-команду. Token не должен содержать приватный WireGuard-ключ.
|
До появления 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
|
## Авторизация Links
|
||||||
|
|
||||||
- мониторинг read-only отделён от управления;
|
- мониторинг 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 string? CertificateAuthorityPassword { get; init; }
|
||||||
|
|
||||||
public int HeartbeatSeconds { get; init; } = 30;
|
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 '',
|
agent_version TEXT NOT NULL DEFAULT '',
|
||||||
last_seen_at TEXT NULL
|
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 (
|
CREATE TABLE IF NOT EXISTS metric_samples (
|
||||||
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
|
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
node_id TEXT NOT NULL REFERENCES agents(node_id) ON DELETE CASCADE,
|
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,
|
subject TEXT NOT NULL,
|
||||||
details_json 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);
|
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
@ -87,6 +119,100 @@ public sealed class ControlStore(IOptions<ControlOptions> options)
|
||||||
return token;
|
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(
|
public async Task<EnrollmentResponse?> EnrollAsync(
|
||||||
EnrollmentRequest request,
|
EnrollmentRequest request,
|
||||||
Func<IssuedCertificate> issueCertificate,
|
Func<IssuedCertificate> issueCertificate,
|
||||||
|
|
@ -164,19 +290,34 @@ public sealed class ControlStore(IOptions<ControlOptions> options)
|
||||||
public async Task<bool> IsCertificateActiveAsync(
|
public async Task<bool> IsCertificateActiveAsync(
|
||||||
string thumbprint,
|
string thumbprint,
|
||||||
CancellationToken cancellationToken = default)
|
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);
|
await using var connection = await OpenAsync(cancellationToken);
|
||||||
var command = connection.CreateCommand();
|
var command = connection.CreateCommand();
|
||||||
command.CommandText = """
|
command.CommandText = """
|
||||||
SELECT EXISTS(
|
SELECT node_id, 'Agent' FROM agents
|
||||||
SELECT 1 FROM agents
|
WHERE certificate_thumbprint = $thumbprint
|
||||||
WHERE certificate_thumbprint = $thumbprint
|
AND certificate_expires_at > $now
|
||||||
AND certificate_expires_at > $now
|
AND status != 'Revoked'
|
||||||
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("$thumbprint", thumbprint);
|
||||||
command.Parameters.AddWithValue("$now", DateTimeOffset.UtcNow.ToString("O"));
|
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(
|
public async Task<bool> IsCertificateForNodeAsync(
|
||||||
|
|
@ -262,6 +403,284 @@ public sealed class ControlStore(IOptions<ControlOptions> options)
|
||||||
return response;
|
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)
|
private async Task<SqliteConnection> OpenAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var connection = new SqliteConnection(_connectionString);
|
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.Claims;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Security.Cryptography.X509Certificates;
|
using System.Security.Cryptography.X509Certificates;
|
||||||
|
using System.Text.Json;
|
||||||
using System.Threading.RateLimiting;
|
using System.Threading.RateLimiting;
|
||||||
using Microsoft.AspNetCore.Authentication.Certificate;
|
using Microsoft.AspNetCore.Authentication.Certificate;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Server.Kestrel.Https;
|
using Microsoft.AspNetCore.Server.Kestrel.Https;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using ServerMonitorManager.Control;
|
using ServerMonitorManager.Control;
|
||||||
using ServerMonitorManager.Core;
|
using ServerMonitorManager.Core;
|
||||||
|
|
@ -37,6 +39,9 @@ builder.Services.AddOptions<ControlOptions>()
|
||||||
.ValidateOnStart();
|
.ValidateOnStart();
|
||||||
builder.Services.AddSingleton<ControlStore>();
|
builder.Services.AddSingleton<ControlStore>();
|
||||||
builder.Services.AddSingleton<CertificateAuthority>();
|
builder.Services.AddSingleton<CertificateAuthority>();
|
||||||
|
builder.Services.AddSingleton<ControlEventBroker>();
|
||||||
|
builder.Services.AddSingleton<ILinkPolicyApplier, LinkPolicyApplier>();
|
||||||
|
builder.Services.AddSingleton<LinkService>();
|
||||||
builder.Services.AddAuthentication(CertificateAuthenticationDefaults.AuthenticationScheme)
|
builder.Services.AddAuthentication(CertificateAuthenticationDefaults.AuthenticationScheme)
|
||||||
.AddCertificate(options =>
|
.AddCertificate(options =>
|
||||||
{
|
{
|
||||||
|
|
@ -49,14 +54,18 @@ builder.Services.AddAuthentication(CertificateAuthenticationDefaults.Authenticat
|
||||||
OnCertificateValidated = async context =>
|
OnCertificateValidated = async context =>
|
||||||
{
|
{
|
||||||
var store = context.HttpContext.RequestServices.GetRequiredService<ControlStore>();
|
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.");
|
context.Fail("The agent certificate is unknown, expired, or revoked.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
context.Principal = new ClaimsPrincipal(new ClaimsIdentity(
|
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.Scheme.Name));
|
||||||
context.Success();
|
context.Success();
|
||||||
}
|
}
|
||||||
|
|
@ -69,7 +78,11 @@ builder.Services.AddOptions<CertificateAuthenticationOptions>(
|
||||||
options.ChainTrustValidationMode = X509ChainTrustMode.CustomRootTrust;
|
options.ChainTrustValidationMode = X509ChainTrustMode.CustomRootTrust;
|
||||||
options.CustomTrustStore.Add(authority.PublicCertificate);
|
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 =>
|
builder.WebHost.ConfigureKestrel(options =>
|
||||||
{
|
{
|
||||||
options.Limits.MaxRequestBodySize = 64 * 1024;
|
options.Limits.MaxRequestBodySize = 64 * 1024;
|
||||||
|
|
@ -99,6 +112,18 @@ if (args is ["token-create", var nodeId])
|
||||||
return 0;
|
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.MapHealthChecks("/healthz").AllowAnonymous();
|
||||||
|
|
||||||
app.MapPost("/api/v1/enroll", async (
|
app.MapPost("/api/v1/enroll", async (
|
||||||
|
|
@ -147,7 +172,49 @@ app.MapPost("/api/v1/enroll", async (
|
||||||
: Results.Ok(response);
|
: Results.Ok(response);
|
||||||
}).AllowAnonymous().RequireRateLimiting("enrollment");
|
}).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 (
|
agents.MapPost("/heartbeat", async (
|
||||||
AgentHeartbeat heartbeat,
|
AgentHeartbeat heartbeat,
|
||||||
HttpContext context,
|
HttpContext context,
|
||||||
|
|
@ -186,6 +253,11 @@ agents.MapPost("/heartbeat", async (
|
||||||
heartbeat,
|
heartbeat,
|
||||||
options.Value.HeartbeatSeconds,
|
options.Value.HeartbeatSeconds,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
var broker = context.RequestServices.GetRequiredService<ControlEventBroker>();
|
||||||
|
broker.Publish(
|
||||||
|
"agent.heartbeat",
|
||||||
|
heartbeat.NodeId,
|
||||||
|
JsonSerializer.Serialize(heartbeat, SmmJsonContext.Default.AgentHeartbeat));
|
||||||
return Results.Ok(response);
|
return Results.Ok(response);
|
||||||
}
|
}
|
||||||
catch (IdempotencyConflictException)
|
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();
|
await app.RunAsync();
|
||||||
return 0;
|
return 0;
|
||||||
|
|
||||||
|
|
@ -215,3 +367,16 @@ internal static class IdempotencyKeyValidator
|
||||||
public static bool IsValid(string value)
|
public static bool IsValid(string value)
|
||||||
=> Guid.TryParse(value, out _);
|
=> 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",
|
"DatabasePath": "/var/lib/ochenstarik-server-monitor-manager/control.db",
|
||||||
"CertificateAuthorityPath": "/etc/ochenstarik-server-monitor-manager/control-ca.pfx",
|
"CertificateAuthorityPath": "/etc/ochenstarik-server-monitor-manager/control-ca.pfx",
|
||||||
"CertificateAuthorityPassword": null,
|
"CertificateAuthorityPassword": null,
|
||||||
"HeartbeatSeconds": 30
|
"HeartbeatSeconds": 30,
|
||||||
|
"HubHelperPath": "/usr/local/libexec/ochenstarik-smm-policy-apply"
|
||||||
},
|
},
|
||||||
"Logging": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
|
|
|
||||||
|
|
@ -37,3 +37,51 @@ public sealed record AgentSummary(
|
||||||
string Status,
|
string Status,
|
||||||
string AgentVersion,
|
string AgentVersion,
|
||||||
DateTimeOffset? LastSeenAt);
|
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(AgentHeartbeat))]
|
||||||
[JsonSerializable(typeof(AgentHeartbeatResponse))]
|
[JsonSerializable(typeof(AgentHeartbeatResponse))]
|
||||||
[JsonSerializable(typeof(AgentSummary[]))]
|
[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;
|
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"
|
Click="SshKeyButton_Click"
|
||||||
Icon="Permissions"
|
Icon="Permissions"
|
||||||
Label="SSH-ключ" />
|
Label="SSH-ключ" />
|
||||||
|
<AppBarButton
|
||||||
|
AutomationProperties.Name="Подключить Control Hub"
|
||||||
|
Click="ControlHubButton_Click"
|
||||||
|
Icon="Link"
|
||||||
|
Label="Control Hub" />
|
||||||
<AppBarButton
|
<AppBarButton
|
||||||
AutomationProperties.Name="Открыть прямой SSH-терминал"
|
AutomationProperties.Name="Открыть прямой SSH-терминал"
|
||||||
Click="TerminalButton_Click"
|
Click="TerminalButton_Click"
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ using Microsoft.UI.Xaml;
|
||||||
using Microsoft.UI.Xaml.Automation;
|
using Microsoft.UI.Xaml.Automation;
|
||||||
using Microsoft.UI.Xaml.Controls;
|
using Microsoft.UI.Xaml.Controls;
|
||||||
using Microsoft.UI.Xaml.Media;
|
using Microsoft.UI.Xaml.Media;
|
||||||
|
using ServerMonitorManager.Core;
|
||||||
using Windows.ApplicationModel.DataTransfer;
|
using Windows.ApplicationModel.DataTransfer;
|
||||||
using Windows.Foundation;
|
using Windows.Foundation;
|
||||||
|
|
||||||
|
|
@ -14,15 +15,19 @@ public sealed partial class MainPage : Page
|
||||||
private readonly ServerStorage _storage = new();
|
private readonly ServerStorage _storage = new();
|
||||||
private readonly SshMonitorService _ssh = new();
|
private readonly SshMonitorService _ssh = new();
|
||||||
private readonly MetricsHistoryStorage _historyStorage = new();
|
private readonly MetricsHistoryStorage _historyStorage = new();
|
||||||
|
private readonly ControlClientService _control = new();
|
||||||
private readonly List<MetricSampleData> _history = [];
|
private readonly List<MetricSampleData> _history = [];
|
||||||
private readonly DispatcherTimer _refreshTimer = new() { Interval = TimeSpan.FromSeconds(30) };
|
private readonly DispatcherTimer _refreshTimer = new() { Interval = TimeSpan.FromSeconds(30) };
|
||||||
private readonly SemaphoreSlim _refreshLock = new(1, 1);
|
private readonly SemaphoreSlim _refreshLock = new(1, 1);
|
||||||
|
private readonly CancellationTokenSource _controlCancellation = new();
|
||||||
private bool _loaded;
|
private bool _loaded;
|
||||||
|
private bool _controlListening;
|
||||||
|
|
||||||
public MainPage()
|
public MainPage()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
Loaded += MainPage_Loaded;
|
Loaded += MainPage_Loaded;
|
||||||
|
Unloaded += (_, _) => _controlCancellation.Cancel();
|
||||||
_refreshTimer.Tick += async (_, _) => await RefreshAllAsync();
|
_refreshTimer.Tick += async (_, _) => await RefreshAllAsync();
|
||||||
MainNavigation.SelectedItem = MainNavigation.MenuItems[0];
|
MainNavigation.SelectedItem = MainNavigation.MenuItems[0];
|
||||||
}
|
}
|
||||||
|
|
@ -56,6 +61,7 @@ public sealed partial class MainPage : Page
|
||||||
{
|
{
|
||||||
await RefreshAllAsync();
|
await RefreshAllAsync();
|
||||||
}
|
}
|
||||||
|
StartControlEvents();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void SshKeyButton_Click(object sender, RoutedEventArgs e)
|
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)
|
private async void AddServerButton_Click(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|
@ -365,7 +502,7 @@ public sealed partial class MainPage : Page
|
||||||
WarningValueText.Text = warnings.ToString(CultureInfo.InvariantCulture);
|
WarningValueText.Text = warnings.ToString(CultureInfo.InvariantCulture);
|
||||||
WarningDetailText.Text = warnings == 0 ? "Нет предупреждений" : "Проверьте доступность и ресурсы";
|
WarningDetailText.Text = warnings == 0 ? "Нет предупреждений" : "Проверьте доступность и ресурсы";
|
||||||
HeaderStatusText.Text = $"SSH monitoring · {Servers.Count} сервер(а) · обновлено {DateTime.Now:HH:mm:ss}";
|
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);
|
await RefreshMeshAsync(showSuccess: false);
|
||||||
}
|
}
|
||||||
|
|
@ -531,6 +668,12 @@ public sealed partial class MainPage : Page
|
||||||
|
|
||||||
private async Task RefreshMeshAsync(bool showSuccess = true)
|
private async Task RefreshMeshAsync(bool showSuccess = true)
|
||||||
{
|
{
|
||||||
|
if (_control.IsConfigured)
|
||||||
|
{
|
||||||
|
await RefreshControlMeshAsync(showSuccess);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var hub = FindHub();
|
var hub = FindHub();
|
||||||
if (hub is null)
|
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)
|
private async void RefreshMeshButton_Click(object sender, RoutedEventArgs e)
|
||||||
=> await RefreshMeshAsync();
|
=> await RefreshMeshAsync();
|
||||||
|
|
||||||
|
|
@ -607,8 +801,9 @@ public sealed partial class MainPage : Page
|
||||||
|
|
||||||
private async Task ChangeLinkAsync(bool enable)
|
private async Task ChangeLinkAsync(bool enable)
|
||||||
{
|
{
|
||||||
|
var useControl = _control.IsConfigured;
|
||||||
var hub = FindHub();
|
var hub = FindHub();
|
||||||
if (hub is null)
|
if (!useControl && hub is null)
|
||||||
{
|
{
|
||||||
ShowInfo("Mesh Hub не выбран", "Сначала добавьте главный сервер с отметкой Mesh Hub.", InfoBarSeverity.Warning);
|
ShowInfo("Mesh Hub не выбран", "Сначала добавьте главный сервер с отметкой Mesh Hub.", InfoBarSeverity.Warning);
|
||||||
return;
|
return;
|
||||||
|
|
@ -668,13 +863,49 @@ public sealed partial class MainPage : Page
|
||||||
|
|
||||||
try
|
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 action = enable ? "connect" : "disconnect";
|
||||||
var policyArguments = enable
|
var policyArguments = enable
|
||||||
? $"{protocol} {port} {ttlMinutes}"
|
? $"{protocol} {port} {ttlMinutes}"
|
||||||
: $"{protocol} {port}";
|
: $"{protocol} {port}";
|
||||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15));
|
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15));
|
||||||
var commandOutput = await _ssh.RunRestrictedCommandAsync(
|
var commandOutput = await _ssh.RunRestrictedCommandAsync(
|
||||||
hub.Profile,
|
hub!.Profile,
|
||||||
$"mesh {action} {source.Name} {target.Name} {policyArguments}",
|
$"mesh {action} {source.Name} {target.Name} {policyArguments}",
|
||||||
timeout.Token);
|
timeout.Token);
|
||||||
var confirmation = commandOutput
|
var confirmation = commandOutput
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,8 @@ public sealed class MeshLinkViewModel
|
||||||
int port,
|
int port,
|
||||||
long expiresUnix,
|
long expiresUnix,
|
||||||
string state,
|
string state,
|
||||||
long version)
|
long version,
|
||||||
|
string? id = null)
|
||||||
{
|
{
|
||||||
Source = source;
|
Source = source;
|
||||||
Target = target;
|
Target = target;
|
||||||
|
|
@ -37,6 +38,7 @@ public sealed class MeshLinkViewModel
|
||||||
ExpiresUnix = expiresUnix;
|
ExpiresUnix = expiresUnix;
|
||||||
State = state;
|
State = state;
|
||||||
Version = version;
|
Version = version;
|
||||||
|
Id = id;
|
||||||
}
|
}
|
||||||
|
|
||||||
public string Source { get; set; }
|
public string Source { get; set; }
|
||||||
|
|
@ -47,6 +49,7 @@ public sealed class MeshLinkViewModel
|
||||||
public long ExpiresUnix { get; set; }
|
public long ExpiresUnix { get; set; }
|
||||||
public string State { get; set; }
|
public string State { get; set; }
|
||||||
public long Version { get; set; }
|
public long Version { get; set; }
|
||||||
|
public string? Id { get; set; }
|
||||||
public string ExpirationText => ExpiresUnix == 0
|
public string ExpirationText => ExpiresUnix == 0
|
||||||
? "вручную"
|
? "вручную"
|
||||||
: $"до {DateTimeOffset.FromUnixTimeSeconds(ExpiresUnix).ToLocalTime():dd.MM HH:mm}";
|
: $"до {DateTimeOffset.FromUnixTimeSeconds(ExpiresUnix).ToLocalTime():dd.MM HH:mm}";
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@
|
||||||
<PropertyGroup> above to opt out.
|
<PropertyGroup> above to opt out.
|
||||||
-->
|
-->
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\ServerMonitorManager.Core\ServerMonitorManager.Core.csproj" />
|
||||||
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.28000.2270" />
|
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.28000.2270" />
|
||||||
<PackageReference Include="Microsoft.WindowsAppSDK" Version="2.2.0" />
|
<PackageReference Include="Microsoft.WindowsAppSDK" Version="2.2.0" />
|
||||||
<PackageReference Include="Microsoft.Windows.SDK.BuildTools.WinApp" Version="0.4.0" />
|
<PackageReference Include="Microsoft.Windows.SDK.BuildTools.WinApp" Version="0.4.0" />
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,112 @@ public sealed class ControlStoreTests : IAsyncDisposable
|
||||||
cancellationToken));
|
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()
|
public ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
SqliteConnection.ClearAllPools();
|
SqliteConnection.ClearAllPools();
|
||||||
|
|
@ -81,4 +187,44 @@ public sealed class ControlStoreTests : IAsyncDisposable
|
||||||
CertificateAuthorityPath = Path.Combine(_directory, "unused.pfx")
|
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