Merge pull request #1 from ochenstarik-ui/agent/windows-ssh-monitoring
Add Windows SSH monitoring MVP
39
.github/workflows/linux-control-agent.yml
vendored
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
name: Linux control and agent
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['**']
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-and-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up .NET 10
|
||||
uses: actions/setup-dotnet@v5
|
||||
with:
|
||||
dotnet-version: 10.0.x
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore ServerMonitorManager.slnx
|
||||
|
||||
- name: Build
|
||||
run: dotnet build ServerMonitorManager.slnx --configuration Release --no-restore
|
||||
|
||||
- name: Test
|
||||
run: dotnet test tests/ServerMonitorManager.Control.Tests/ServerMonitorManager.Control.Tests.csproj --configuration Release --no-build
|
||||
|
||||
- name: Verify formatting
|
||||
run: dotnet format ServerMonitorManager.slnx --verify-no-changes --no-restore
|
||||
|
||||
- name: Publish agent amd64
|
||||
run: dotnet publish src/ServerMonitorManager.Agent/ServerMonitorManager.Agent.csproj --configuration Release --runtime linux-x64 --self-contained true -p:PublishSingleFile=true -p:PublishTrimmed=true
|
||||
|
||||
- name: Publish agent arm64
|
||||
run: dotnet publish src/ServerMonitorManager.Agent/ServerMonitorManager.Agent.csproj --configuration Release --runtime linux-arm64 --self-contained true -p:PublishSingleFile=true -p:PublishTrimmed=true
|
||||
56
.github/workflows/linux-release.yml
vendored
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
name: Linux release artifacts
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
runtime: [linux-x64, linux-arm64]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up .NET 10
|
||||
uses: actions/setup-dotnet@v5
|
||||
with:
|
||||
dotnet-version: 10.0.x
|
||||
|
||||
- name: Publish agent
|
||||
run: dotnet publish src/ServerMonitorManager.Agent/ServerMonitorManager.Agent.csproj --configuration Release --runtime ${{ matrix.runtime }} --self-contained true -p:PublishSingleFile=true -p:PublishTrimmed=true -o out/agent
|
||||
|
||||
- name: Publish control
|
||||
run: dotnet publish src/ServerMonitorManager.Control/ServerMonitorManager.Control.csproj --configuration Release --runtime ${{ matrix.runtime }} --self-contained true -p:PublishSingleFile=true -p:PublishTrimmed=true -o out/control
|
||||
|
||||
- name: Package
|
||||
shell: bash
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
archive="server-monitor-manager-${{ matrix.runtime }}.tar.gz"
|
||||
tar -C out -czf "$archive" agent control
|
||||
sha256sum "$archive" > "$archive.sha256"
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: server-monitor-manager-${{ matrix.runtime }}
|
||||
path: |
|
||||
server-monitor-manager-${{ matrix.runtime }}.tar.gz
|
||||
server-monitor-manager-${{ matrix.runtime }}.tar.gz.sha256
|
||||
|
||||
- name: Attach to GitHub Release
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
prerelease: ${{ contains(github.ref_name, '-') }}
|
||||
files: |
|
||||
server-monitor-manager-${{ matrix.runtime }}.tar.gz
|
||||
server-monitor-manager-${{ matrix.runtime }}.tar.gz.sha256
|
||||
30
.github/workflows/windows-build.yml
vendored
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
name: Windows build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['**']
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up .NET 10
|
||||
uses: actions/setup-dotnet@v5
|
||||
with:
|
||||
dotnet-version: 10.0.x
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore src/ServerMonitorManager.Desktop/ServerMonitorManager.Desktop.csproj -p:Platform=x64 -p:PublishReadyToRun=true -r win-x64
|
||||
|
||||
- name: Build x64 Release
|
||||
run: dotnet build src/ServerMonitorManager.Desktop/ServerMonitorManager.Desktop.csproj --configuration Release -p:Platform=x64 -p:PublishReadyToRun=true -r win-x64 --no-restore
|
||||
|
||||
- name: Verify formatting
|
||||
run: dotnet format src/ServerMonitorManager.Desktop/ServerMonitorManager.Desktop.csproj --verify-no-changes --no-restore
|
||||
15
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
.vs/
|
||||
.idea/
|
||||
.vscode/
|
||||
bin/
|
||||
obj/
|
||||
artifacts/
|
||||
.artifacts/
|
||||
TestResults/
|
||||
*.user
|
||||
*.suo
|
||||
*.pfx
|
||||
*.key
|
||||
*.pem
|
||||
*.token
|
||||
appsettings.Production.json
|
||||
134
README.md
|
|
@ -1,29 +1,127 @@
|
|||
# Mobile Server Manager
|
||||
# Server Monitor Manager
|
||||
|
||||
Мобильная система управления Linux-серверами: отчёты, безопасные команды, резервные копии и прямые SSH-сессии.
|
||||
**English** · [Русский](docs/i18n/README.ru.md) · [Español](docs/i18n/README.es.md) · [简体中文](docs/i18n/README.zh-CN.md) · [हिन्दी](docs/i18n/README.hi.md) · [العربية](docs/i18n/README.ar.md) · [Português](docs/i18n/README.pt-BR.md) · [Français](docs/i18n/README.fr.md) · [Deutsch](docs/i18n/README.de.md) · [日本語](docs/i18n/README.ja.md) · [한국어](docs/i18n/README.ko.md) · [Türkçe](docs/i18n/README.tr.md)
|
||||
|
||||
## Структура
|
||||
Server Monitor Manager is a lightweight, Windows-first application for monitoring Linux servers, opening direct SSH sessions, and explicitly controlling secure connections between servers. It is designed for personal infrastructure and small fleets where a heavy monitoring platform, Kubernetes, or a public API on every node would be unnecessary.
|
||||
|
||||
- `mobile/` — Flutter-клиент для Android и iOS;
|
||||
- `src/ServerManager.Api/` — ASP.NET Core 10 API, Identity, TOTP 2FA и SignalR;
|
||||
- `src/ServerManager.Agent/` — исходящий агент управляемого Linux-сервера;
|
||||
- `deploy/` — установщик и systemd-файлы агента;
|
||||
- `docs/` — архитектура и модель безопасности.
|
||||
The current alpha combines a packaged WinUI 3 desktop client, an ASP.NET Core control service, a small outbound Linux agent, SQLite storage, and a WireGuard data plane managed by restrictive nftables policies.
|
||||
|
||||
## Статус
|
||||
## What it does
|
||||
|
||||
Проект находится на раннем этапе разработки. Первая версия API содержит базовую модель пользователей и серверов. Опасные команды, удалённое восстановление и SSH-терминал будут включаться только после реализации аудита, повторного подтверждения и проверки полномочий.
|
||||
- monitors CPU/load, memory, swap, disks, inodes, network activity, uptime, latency, SSH, and WireGuard;
|
||||
- keeps several server profiles, groups, tags, favorites, alerts, and short local metric history;
|
||||
- generates a dedicated Ed25519 SSH key and stores private material only on the Windows device;
|
||||
- opens direct SSH terminals without sending a private terminal key to the Hub;
|
||||
- joins servers through one Hub with a public IP; secondary servers need outbound access only;
|
||||
- creates directional Links such as `AI agent → Home server:22` and disables each Link independently;
|
||||
- limits Links by source, destination `/32`, TCP/UDP port, policy version, and optional TTL;
|
||||
- uses one-time enrollment tokens, CSR-based certificates, mTLS, role separation, idempotency, and audit records;
|
||||
- runs without Docker or a database on every Node.
|
||||
|
||||
Сейчас форма мобильного входа является интерфейсным прототипом и намеренно не пропускает пользователя без API. Управление серверами в API требует включённой TOTP-2FA. Production-запуск заблокирован до подключения PostgreSQL и миграций; агент пока только собирает локальные метрики и не отправляет их без защищённой регистрации.
|
||||
## Architecture
|
||||
|
||||
## Требования для разработки
|
||||
```text
|
||||
Windows desktop -- mTLS/HTTPS --> Control Hub (ASP.NET Core + SQLite)
|
||||
| |
|
||||
+-------- direct SSH ----------+
|
||||
|
|
||||
WireGuard + nftables
|
||||
/ | \
|
||||
AI-agent Home Server 2
|
||||
```
|
||||
|
||||
The Hub has a public IP and coordinates the fleet. Nodes initiate their own WireGuard and mTLS connections, so a home server behind NAT does not need a white/dedicated IP or an inbound public port. Transit is denied by default. A Link is directional: enabling `AI-agent → Home` does not enable `Home → AI-agent` or access to another server.
|
||||
|
||||
The control plane and data plane are separated:
|
||||
|
||||
- **Control plane:** ASP.NET Core 10, SQLite inventory, metrics, policies, history, audit, enrollment, and an authenticated event stream.
|
||||
- **Data plane:** WireGuard peers and persistent nftables ACLs on the Hub.
|
||||
- **Desktop:** packaged WinUI 3 client with DPAPI-protected operator certificate and SSH identity.
|
||||
- **Agent:** self-contained Linux binary for `amd64` and `arm64`; it only creates outbound mTLS sessions.
|
||||
|
||||
See [architecture](docs/architecture.md), [security model](docs/security-model.md), [roadmap](docs/roadmap.md), and [installer contract](docs/installer-contract.md).
|
||||
|
||||
## Repository layout
|
||||
|
||||
```text
|
||||
src/ServerMonitorManager.Desktop/ Windows WinUI 3 client
|
||||
src/ServerMonitorManager.Core/ Shared contracts and models
|
||||
src/ServerMonitorManager.Control/ Hub API, SQLite, events, and policy coordination
|
||||
src/ServerMonitorManager.Agent/ Outbound Linux monitoring agent
|
||||
tests/ Control-plane tests
|
||||
docs/ Architecture, security, roadmap, translations
|
||||
```
|
||||
|
||||
The Linux installer is maintained in [`ochenstarik-ui/lightweight-server`](https://github.com/ochenstarik-ui/lightweight-server) as `ochenstarik-server-monitor-manager.sh`. Release binaries are attached to [Server Monitor Manager releases](https://github.com/ochenstarik-ui/server-monitor-manager/releases).
|
||||
|
||||
## Quick start: Hub and two Nodes
|
||||
|
||||
Use a fresh Debian or Ubuntu server with a public IP as the Hub. Download and inspect the installer before running it:
|
||||
|
||||
```bash
|
||||
curl -fLO https://raw.githubusercontent.com/ochenstarik-ui/lightweight-server/main/ochenstarik-server-monitor-manager.sh
|
||||
chmod 700 ochenstarik-server-monitor-manager.sh
|
||||
bash -n ochenstarik-server-monitor-manager.sh
|
||||
sudo ./ochenstarik-server-monitor-manager.sh hub
|
||||
```
|
||||
|
||||
Open the selected WireGuard UDP port (default `51820`) and Control Hub TCP port `7443`. Create enrollment codes on the Hub:
|
||||
|
||||
```bash
|
||||
sudo ochenstarik-smm node-code home
|
||||
sudo ochenstarik-smm node-code ai-agent
|
||||
```
|
||||
|
||||
On each secondary server, use the same installer and select the Node role. Paste the code for that Node. The private WireGuard key is created locally and never leaves the Node. Then install the persistent control layer:
|
||||
|
||||
```bash
|
||||
# Hub
|
||||
sudo ./ochenstarik-server-monitor-manager.sh install-control-hub
|
||||
sudo ./ochenstarik-server-monitor-manager.sh control-code home
|
||||
sudo ./ochenstarik-server-monitor-manager.sh control-device-code windows-pc
|
||||
|
||||
# Node: paste the corresponding SMMCTL1 code when prompted
|
||||
sudo ./ochenstarik-server-monitor-manager.sh install-control-agent
|
||||
```
|
||||
|
||||
The installer selects the `amd64` or `arm64` archive and verifies its SHA-256 checksum. The `SMMDEV1` code enrolls the Windows application: the app creates its operator key locally, confirms the Hub CA fingerprint, obtains a separate certificate, and protects it with Windows DPAPI.
|
||||
|
||||
## Windows client
|
||||
|
||||
Requirements for building from source:
|
||||
|
||||
- Windows 10 version 1809 or later / Windows 11;
|
||||
- .NET 10 SDK;
|
||||
- Flutter stable с Android SDK;
|
||||
- Android Studio или Android command-line tools;
|
||||
- для сборки iOS — macOS с Xcode;
|
||||
- PostgreSQL для production; SQLite используется только при локальной разработке.
|
||||
- Visual Studio 2022 with Windows App SDK tooling, or compatible CLI workloads;
|
||||
- system OpenSSH client.
|
||||
|
||||
Секреты, токены агентов и приватные SSH-ключи не должны попадать в Git.
|
||||
```powershell
|
||||
dotnet build ServerMonitorManager.slnx --configuration Release
|
||||
dotnet test tests/ServerMonitorManager.Control.Tests/ServerMonitorManager.Control.Tests.csproj --configuration Release
|
||||
```
|
||||
|
||||
Подробный результат проверки ПК и порядок подготовки среды находятся в [`docs/development-setup.md`](docs/development-setup.md).
|
||||
In the application, generate or copy the monitoring SSH key, add the Hub profile, mark it as the Mesh Hub, and use **Control Hub** to paste the `SMMDEV1` code. The Mesh view then reads inventory and Links from the authenticated Control API and receives live Link/heartbeat events.
|
||||
|
||||
## Security model
|
||||
|
||||
- no shared root password and no Node private WireGuard key on the Hub;
|
||||
- separate monitoring, terminal, Agent, Operator, and AI-automation identities;
|
||||
- SSH monitoring uses a root-owned forced command without shell, PTY, or forwarding;
|
||||
- Agent certificates can only submit heartbeat data for their own Node;
|
||||
- Operator certificates are required for inventory, Links, and event streaming;
|
||||
- Link traffic is denied by default and allowed only by explicit nftables rules;
|
||||
- disabling a Link persists the desired state before the firewall rule is removed;
|
||||
- idempotency keys prevent a retry from repeating a policy side effect;
|
||||
- secrets and production configuration must never be committed to Git.
|
||||
|
||||
## Current status
|
||||
|
||||
`v0.1.0-alpha.2` is an early testing release, not a production security appliance. Windows and Linux builds, six control-plane tests, Bash syntax checks, self-contained `linux-x64`/`linux-arm64` artifacts, and checksums are automated in GitHub Actions.
|
||||
|
||||
Implemented: Windows SSH monitoring, Hub/Node WireGuard installer, directional Links, one-time enrollment, mTLS Agent and Operator identities, SQLite control state, audit, authenticated event stream, and Windows Control API integration.
|
||||
|
||||
Still planned: certificate revoke/re-enroll, offline Agent buffering and downsampling, reconnect reconciliation, kill-switch failure tests, a 50–100 Node load test, signed Windows installer, and desktop/mobile clients for additional platforms.
|
||||
|
||||
## Project policy
|
||||
|
||||
The project is under active alpha development. Review scripts and release checksums before testing, use disposable or backed-up servers, and do not expose the Control port without firewall restrictions. A source license will be selected before the first stable public release; until then, the repository does not grant an implied open-source license.
|
||||
|
|
|
|||
6
ServerMonitorManager.slnx
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<Solution>
|
||||
<Project Path="src/ServerMonitorManager.Agent/ServerMonitorManager.Agent.csproj" />
|
||||
<Project Path="src/ServerMonitorManager.Control/ServerMonitorManager.Control.csproj" />
|
||||
<Project Path="src/ServerMonitorManager.Core/ServerMonitorManager.Core.csproj" />
|
||||
<Project Path="tests/ServerMonitorManager.Control.Tests/ServerMonitorManager.Control.Tests.csproj" />
|
||||
</Solution>
|
||||
28
deploy/ochenstarik-smm-agent.service
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
[Unit]
|
||||
Description=Ochenstarik Server Monitor Manager Agent
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=ochenstarik-smm-agent
|
||||
Group=ochenstarik-smm-agent
|
||||
EnvironmentFile=/etc/ochenstarik-server-monitor-manager/agent.env
|
||||
ExecStart=/usr/local/lib/ochenstarik-server-monitor-manager/agent/ochenstarik-smm-agent
|
||||
Restart=on-failure
|
||||
RestartSec=10s
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
PrivateDevices=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
LockPersonality=true
|
||||
RestrictSUIDSGID=true
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
ReadWritePaths=/var/lib/ochenstarik-server-monitor-manager/agent
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
27
deploy/ochenstarik-smm-control.service
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
[Unit]
|
||||
Description=Ochenstarik Server Monitor Manager Control Hub
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=ochenstarik-smm-control
|
||||
Group=ochenstarik-smm-control
|
||||
EnvironmentFile=/etc/ochenstarik-server-monitor-manager/control.env
|
||||
ExecStart=/usr/local/lib/ochenstarik-server-monitor-manager/control/ochenstarik-smm-control
|
||||
Restart=on-failure
|
||||
RestartSec=10s
|
||||
PrivateTmp=true
|
||||
PrivateDevices=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
LockPersonality=true
|
||||
RestrictSUIDSGID=true
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
ReadWritePaths=/var/lib/ochenstarik-server-monitor-manager
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
113
docs/architecture.md
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
# Архитектура Server Monitor Manager
|
||||
|
||||
## 1. Выбранная топология
|
||||
|
||||
Первый рабочий вариант использует один **Mesh Hub** на Linux-сервере с белым IP. Остальные узлы не требуют входящего публичного порта и устанавливают исходящее WireGuard-соединение с Hub.
|
||||
|
||||
```text
|
||||
Windows client -- ограниченный SSH --> Mesh Hub (публичный UDP)
|
||||
^
|
||||
|
|
||||
исходящие WireGuard-туннели
|
||||
|
|
||||
AI-agent / Home / Server2 / ...
|
||||
```
|
||||
|
||||
Hub выполняет две разные функции:
|
||||
|
||||
- control plane первого MVP: список узлов, политики Links и команды управления через ограниченный SSH forced-command;
|
||||
- data plane первого MVP: маршрутизация WireGuard-трафика с обязательной фильтрацией nftables.
|
||||
|
||||
Это осознанная звёздная топология. Прямые peer-to-peer соединения, relay и отказоустойчивый второй Hub не входят в первый MVP.
|
||||
|
||||
## 2. Текущий переходный режим
|
||||
|
||||
### Windows client
|
||||
|
||||
Packaged WinUI 3 приложение хранит профили серверов локально, создаёт отдельную Ed25519 identity мониторинга и вызывает только разрешённые SSH-команды. Клиент показывает snapshot метрик, узлы Mesh и направленные Links.
|
||||
|
||||
### SSH monitoring endpoint
|
||||
|
||||
На Hub и Node создаётся непривилегированный пользователь `ochenstarik-monitor`. Его ключ привязан к root-owned forced-command и не даёт shell, PTY или forwarding. В текущем протоколе доступны `metrics` и ограниченные команды `mesh` на Hub.
|
||||
|
||||
### Mesh Hub
|
||||
|
||||
Hub хранит:
|
||||
|
||||
- публичные WireGuard identities узлов;
|
||||
- выданные внутренние адреса;
|
||||
- желаемое и фактическое состояние Links;
|
||||
- политики CIDR, протокола, порта и TTL;
|
||||
- журнал управляющих операций.
|
||||
|
||||
Приватный WireGuard-ключ каждого Node создаётся на самом Node и никогда не передаётся Hub. Временный enrollment token не является ключом узла.
|
||||
|
||||
### Node
|
||||
|
||||
Node устанавливает исходящее WireGuard-соединение с Hub. Входящий публичный порт не нужен. Node применяет выданную конфигурацию, подтверждает её версию и хранит приватный ключ с правами `0600`.
|
||||
|
||||
## 3. Метрики
|
||||
|
||||
Текущий SSH snapshot содержит CPU/load, память, корневой диск, uptime и задержку. Следующая версия добавляет swap, inode, сетевые счётчики и выбранные systemd units.
|
||||
|
||||
Постоянный Linux agent появится после стабилизации трёхсерверного сценария. Он будет отправлять метрики исходящим mTLS-соединением, вести ограниченный локальный буфер и не открывать публичный API.
|
||||
|
||||
## 4. Links между серверами
|
||||
|
||||
Link — направленный ресурс:
|
||||
|
||||
```text
|
||||
Draft -> Connecting -> Active -> Disconnecting -> Disabled
|
||||
\-> Partial
|
||||
\-> Failed
|
||||
Active -> Expired -> Disabled
|
||||
```
|
||||
|
||||
Каждый Link содержит:
|
||||
|
||||
- source и destination;
|
||||
- разрешённый IP или CIDR;
|
||||
- протокол TCP/UDP и список портов;
|
||||
- ручной режим или TTL;
|
||||
- причину, владельца и запись аудита;
|
||||
- версию политики и подтверждение применения.
|
||||
|
||||
Пустая политика не означает `allow all`. Ответный трафик существующего соединения разрешается stateful-правилом, обратное новое соединение требует отдельного Link.
|
||||
|
||||
При `Disconnect` Hub сначала блокирует направление в nftables, затем фиксирует `Disabled`. Если подтверждение узла недоступно, интерфейс показывает `Partial`, а обязательное состояние применяется после reconnect.
|
||||
|
||||
## 5. Сценарий AI-агента
|
||||
|
||||
1. На целевом сервере создаётся отдельный Unix-пользователь, например `ai-agent-dev`, без root-доступа.
|
||||
2. Рабочие каталоги и команды ограничиваются Unix-правами, группами, контейнером или sandbox-профилем.
|
||||
3. Создаётся Link от узла с AI-агентом к целевому адресу и только необходимому SSH-порту.
|
||||
4. AI-агент использует отдельную SSH identity, не ключ мониторинга приложения.
|
||||
5. Пользователь включает Link на ограниченное время и после работы отключает его.
|
||||
|
||||
## 6. Следующий control layer
|
||||
|
||||
После проверки текущей топологии SSH-команды управления заменяются небольшим control service:
|
||||
|
||||
- SQLite для инвентаря, политик, истории и аудита;
|
||||
- одноразовая enrollment-регистрация;
|
||||
- исходящие mTLS-сессии агентов;
|
||||
- WebSocket/stream событий для desktop client;
|
||||
- idempotency key и защита от replay.
|
||||
|
||||
Hub остаётся маршрутизатором Mesh первого поколения. Разделение control plane и data plane возможно позже без изменения модели направленных Links.
|
||||
|
||||
Первый реализованный срез control layer использует ASP.NET Core 10 и SQLite. Hub выдаёт агенту сертификат по CSR только после атомарного погашения десятиминутного token. После регистрации Agent выполняет только исходящие HTTPS-запросы с mTLS, а Hub связывает thumbprint сертификата с конкретным `node_id`. Heartbeat содержит idempotency key и отклоняется при попытке повторить тот же ключ с другим телом запроса.
|
||||
|
||||
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
|
||||
|
||||
- без Kubernetes и обязательного Docker;
|
||||
- отсутствие публичного agent API;
|
||||
- вторичные серверы работают без белого IP;
|
||||
- 50–100 узлов на одном небольшом Hub;
|
||||
- Node agent: idle RAM до 50 МБ и CPU менее 1%;
|
||||
- команды не повторяются без idempotency key;
|
||||
- потеря истории метрик не должна приводить к потере управления Links.
|
||||
37
docs/i18n/README.ar.md
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# Server Monitor Manager
|
||||
|
||||
[English](../../README.md) · [Русский](README.ru.md) · [Español](README.es.md) · [简体中文](README.zh-CN.md) · [हिन्दी](README.hi.md) · **العربية** · [Português](README.pt-BR.md) · [Français](README.fr.md) · [Deutsch](README.de.md) · [日本語](README.ja.md) · [한국어](README.ko.md) · [Türkçe](README.tr.md)
|
||||
|
||||
Server Monitor Manager هو تطبيق خفيف يبدأ بمنصة Windows لمراقبة خوادم Linux وفتح جلسات SSH مباشرة والتحكم الصريح في الاتصالات الآمنة بين الخوادم. صُمم للبنية الشخصية والمجموعات الصغيرة من دون لوحة ثقيلة أو Kubernetes أو API عام على كل عقدة.
|
||||
|
||||
## الميزات
|
||||
|
||||
- مراقبة CPU/load والذاكرة وswap والأقراص وinode والشبكة وuptime وزمن الاستجابة وSSH وWireGuard؛
|
||||
- ملفات خوادم ومجموعات ووسوم ومفضلة وتنبيهات وسجل محلي قصير؛
|
||||
- مفتاح SSH Ed25519 مستقل يبقى مفتاحه الخاص على جهاز Windows؛
|
||||
- طرفية SSH مباشرة من دون إرسال المفتاح الخاص إلى Hub؛
|
||||
- شبكة نجمية عبر Hub ذي IP عام، بينما تحتاج Nodes إلى اتصال صادر فقط؛
|
||||
- روابط اتجاهية مثل `AI agent → Home server:22` يمكن فصلها بشكل مستقل؛
|
||||
- سياسات `/32` وTCP/UDP والمنفذ والإصدار وTTL؛
|
||||
- تسجيل لمرة واحدة وCSR وmTLS وفصل أدوار ومنع التكرار وتدقيق.
|
||||
|
||||
## البنية والتثبيت
|
||||
|
||||
يتصل عميل Windows عبر mTLS/HTTPS بـ Control Hub مبني على ASP.NET Core 10 وSQLite. ينشئ Linux Agent جلسات صادرة فقط. ينقل WireGuard البيانات وتمنع nftables العبور افتراضياً. الرابط أحادي الاتجاه، ولا يحتاج الخادم المنزلي خلف NAT إلى IP عام.
|
||||
|
||||
```bash
|
||||
curl -fLO https://raw.githubusercontent.com/ochenstarik-ui/lightweight-server/main/ochenstarik-server-monitor-manager.sh
|
||||
chmod 700 ochenstarik-server-monitor-manager.sh
|
||||
bash -n ochenstarik-server-monitor-manager.sh
|
||||
sudo ./ochenstarik-server-monitor-manager.sh hub
|
||||
```
|
||||
|
||||
افتح UDP `51820` وTCP `7443` على Hub، وأنشئ رموز Nodes وثبت بقية الخوادم بدور Node. ثم استخدم `install-control-hub` و`control-code` و`control-device-code` و`install-control-agent`. يختار المثبت `amd64` أو `arm64` ويتحقق من SHA-256.
|
||||
|
||||
## الأمان والحالة
|
||||
|
||||
لا توجد كلمة مرور root مشتركة ولا يغادر مفتاح WireGuard الخاص عقدته. هويات monitoring وterminal وAgent وOperator وأتمتة AI منفصلة. يستخدم SSH أمراً إجبارياً بلا shell أو PTY أو forwarding؛ يقيّد mTLS الصلاحيات؛ تسمح nftables بالروابط الصريحة فقط؛ ويحفظ SQLite الحالة المطلوبة والتدقيق قبل تعديل الجدار الناري.
|
||||
|
||||
الإصدار `v0.1.0-alpha.2` للاختبار. يتضمن عميل Windows ومثبت Hub/Node وLinks وmTLS وSQLite والتدقيق والأحداث. المتبقي: التخزين دون اتصال، إلغاء الشهادات، مصالحة إعادة الاتصال، اختبار 50–100 Node ومثبت Windows موقّع.
|
||||
|
||||
الوثائق: [البنية](../architecture.md)، [الأمان](../security-model.md)، [الخطة](../roadmap.md)، [عقد المثبت](../installer-contract.md).
|
||||
37
docs/i18n/README.de.md
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# Server Monitor Manager
|
||||
|
||||
[English](../../README.md) · [Русский](README.ru.md) · [Español](README.es.md) · [简体中文](README.zh-CN.md) · [हिन्दी](README.hi.md) · [العربية](README.ar.md) · [Português](README.pt-BR.md) · [Français](README.fr.md) · **Deutsch** · [日本語](README.ja.md) · [한국어](README.ko.md) · [Türkçe](README.tr.md)
|
||||
|
||||
Server Monitor Manager ist eine schlanke, zunächst für Windows entwickelte Anwendung zur Überwachung von Linux-Servern, für direkte SSH-Sitzungen und zur ausdrücklichen Steuerung sicherer Serververbindungen. Sie richtet sich an private Infrastruktur und kleine Flotten ohne schwergewichtiges Dashboard, Kubernetes oder öffentliche API auf jedem Node.
|
||||
|
||||
## Funktionen
|
||||
|
||||
- CPU/load, Speicher, Swap, Datenträger, Inodes, Netzwerk, Uptime, Latenz, SSH und WireGuard;
|
||||
- Profile, Gruppen, Tags, Favoriten, Warnungen und kurze lokale Historie;
|
||||
- eigener Ed25519-SSH-Schlüssel, dessen privater Teil auf Windows bleibt;
|
||||
- direktes SSH-Terminal ohne privaten Schlüssel auf dem Hub;
|
||||
- Sternnetz über einen Hub mit öffentlicher IP; Nodes benötigen nur ausgehenden Zugriff;
|
||||
- gerichtete Links wie `KI-Agent → Home-Server:22`, einzeln abschaltbar;
|
||||
- `/32`-, TCP/UDP-, Port-, Versions- und TTL-Regeln;
|
||||
- Einmal-Enrollment, CSR, mTLS, Rollentrennung, Idempotenz und Audit.
|
||||
|
||||
## Architektur und Installation
|
||||
|
||||
Der Windows-Client kommuniziert per mTLS/HTTPS mit einem ASP.NET Core 10 Control Hub und SQLite. Der Linux Agent baut nur ausgehende Sitzungen auf. WireGuard transportiert Daten, nftables sperrt Transit standardmäßig. Ein Link öffnet keine Gegenrichtung; ein Server hinter NAT benötigt keine öffentliche IP.
|
||||
|
||||
```bash
|
||||
curl -fLO https://raw.githubusercontent.com/ochenstarik-ui/lightweight-server/main/ochenstarik-server-monitor-manager.sh
|
||||
chmod 700 ochenstarik-server-monitor-manager.sh
|
||||
bash -n ochenstarik-server-monitor-manager.sh
|
||||
sudo ./ochenstarik-server-monitor-manager.sh hub
|
||||
```
|
||||
|
||||
Öffnen Sie UDP `51820` und TCP `7443` am Hub, erzeugen Sie Codes und installieren Sie weitere Server als Node. Danach folgen `install-control-hub`, `control-code`, `control-device-code` und `install-control-agent`. Der Installer wählt `amd64`/`arm64` und prüft SHA-256.
|
||||
|
||||
## Sicherheit und Status
|
||||
|
||||
Es gibt kein gemeinsames Root-Passwort; der private WireGuard-Schlüssel bleibt auf dem Node. Monitoring-, Terminal-, Agent-, Operator- und KI-Automationsidentitäten sind getrennt. SSH nutzt einen forced-command ohne Shell/PTY/Forwarding; mTLS begrenzt Rollen; nftables erlaubt nur explizite Links; SQLite speichert Sollzustand und Audit vor der Firewalländerung.
|
||||
|
||||
`v0.1.0-alpha.2` ist eine Testversion. Windows-Client, Hub/Node-Installer, Links, mTLS, SQLite, Audit und Events sind vorhanden. Offen sind Offline-Puffer, Zertifikatswiderruf, Reconnect-Abgleich, Lasttest mit 50–100 Nodes und signierter Windows-Installer.
|
||||
|
||||
Dokumentation: [Architektur](../architecture.md), [Sicherheit](../security-model.md), [Roadmap](../roadmap.md), [Installer](../installer-contract.md).
|
||||
39
docs/i18n/README.es.md
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
# Server Monitor Manager
|
||||
|
||||
[English](../../README.md) · [Русский](README.ru.md) · **Español** · [简体中文](README.zh-CN.md) · [हिन्दी](README.hi.md) · [العربية](README.ar.md) · [Português](README.pt-BR.md) · [Français](README.fr.md) · [Deutsch](README.de.md) · [日本語](README.ja.md) · [한국어](README.ko.md) · [Türkçe](README.tr.md)
|
||||
|
||||
Server Monitor Manager es una aplicación ligera, inicialmente para Windows, que monitoriza servidores Linux, abre sesiones SSH directas y controla de forma explícita las conexiones seguras entre servidores. Está pensada para infraestructura personal y flotas pequeñas sin paneles pesados, Kubernetes ni una API pública en cada nodo.
|
||||
|
||||
## Funciones
|
||||
|
||||
- métricas de CPU/load, memoria, swap, discos, inodos, red, uptime, latencia, SSH y WireGuard;
|
||||
- perfiles, grupos, etiquetas, favoritos, alertas e historial local corto;
|
||||
- clave SSH Ed25519 dedicada que permanece en el equipo Windows;
|
||||
- terminal SSH directo sin entregar la clave privada al Hub;
|
||||
- red en estrella mediante un Hub con IP pública; los Nodes solo necesitan salida;
|
||||
- Links direccionales como `agente IA → servidor doméstico:22`, desactivables por separado;
|
||||
- políticas `/32`, TCP/UDP, puerto, versión y TTL;
|
||||
- tokens de un solo uso, CSR, mTLS, roles separados, idempotencia y auditoría.
|
||||
|
||||
## Arquitectura
|
||||
|
||||
El cliente Windows usa mTLS/HTTPS con un Control Hub ASP.NET Core 10 y SQLite. El agente Linux solo inicia sesiones salientes. WireGuard transporta el tráfico y nftables lo bloquea por defecto. Un Link no concede acceso inverso ni acceso a otro servidor, y un servidor doméstico detrás de NAT no necesita IP pública.
|
||||
|
||||
## Instalación rápida
|
||||
|
||||
```bash
|
||||
curl -fLO https://raw.githubusercontent.com/ochenstarik-ui/lightweight-server/main/ochenstarik-server-monitor-manager.sh
|
||||
chmod 700 ochenstarik-server-monitor-manager.sh
|
||||
bash -n ochenstarik-server-monitor-manager.sh
|
||||
sudo ./ochenstarik-server-monitor-manager.sh hub
|
||||
```
|
||||
|
||||
Abra UDP `51820` y TCP `7443` en el Hub, cree códigos para los Nodes e instálelos con el rol Node. Después use `install-control-hub`, `control-code`, `control-device-code` e `install-control-agent`. El instalador selecciona `amd64`/`arm64` y verifica SHA-256.
|
||||
|
||||
## Seguridad y estado
|
||||
|
||||
No hay contraseña root compartida ni claves WireGuard privadas de Nodes en el Hub. Las identidades de monitorización, terminal, Agent, Operator y automatización IA están separadas. SSH usa un forced-command sin shell, PTY ni forwarding; mTLS limita cada rol; nftables permite únicamente Links explícitos; SQLite conserva estado y auditoría antes de aplicar cambios.
|
||||
|
||||
`v0.1.0-alpha.2` es una versión de prueba. Ya incluye cliente Windows, instalador Hub/Node, Links, mTLS, SQLite, auditoría y eventos. Faltan el búfer offline, revocación de certificados, reconciliación tras reconexión, prueba de 50–100 Nodes e instalador Windows firmado.
|
||||
|
||||
Documentación: [arquitectura](../architecture.md), [seguridad](../security-model.md), [plan](../roadmap.md), [instalador](../installer-contract.md).
|
||||
37
docs/i18n/README.fr.md
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# Server Monitor Manager
|
||||
|
||||
[English](../../README.md) · [Русский](README.ru.md) · [Español](README.es.md) · [简体中文](README.zh-CN.md) · [हिन्दी](README.hi.md) · [العربية](README.ar.md) · [Português](README.pt-BR.md) · **Français** · [Deutsch](README.de.md) · [日本語](README.ja.md) · [한국어](README.ko.md) · [Türkçe](README.tr.md)
|
||||
|
||||
Server Monitor Manager est une application légère, d'abord conçue pour Windows, qui surveille des serveurs Linux, ouvre des sessions SSH directes et contrôle explicitement les connexions sécurisées entre serveurs. Elle vise les infrastructures personnelles et petites flottes sans tableau de bord lourd, Kubernetes ni API publique sur chaque nœud.
|
||||
|
||||
## Fonctionnalités
|
||||
|
||||
- CPU/load, mémoire, swap, disques, inodes, réseau, uptime, latence, SSH et WireGuard ;
|
||||
- profils, groupes, tags, favoris, alertes et historique local court ;
|
||||
- clé SSH Ed25519 dédiée dont la partie privée reste sur Windows ;
|
||||
- terminal SSH direct sans transmettre la clé privée au Hub ;
|
||||
- réseau en étoile via un Hub à IP publique ; les Nodes n'ont besoin que d'un accès sortant ;
|
||||
- Links directionnels comme `agent IA → serveur maison:22`, désactivables séparément ;
|
||||
- politiques `/32`, TCP/UDP, port, version et TTL ;
|
||||
- enrôlement unique, CSR, mTLS, rôles séparés, idempotence et audit.
|
||||
|
||||
## Architecture et installation
|
||||
|
||||
Le client Windows communique en mTLS/HTTPS avec un Control Hub ASP.NET Core 10 et SQLite. L'Agent Linux ne crée que des sessions sortantes. WireGuard transporte les données et nftables bloque le transit par défaut. Un Link n'ouvre pas le sens inverse ; un serveur derrière NAT n'a pas besoin d'IP publique.
|
||||
|
||||
```bash
|
||||
curl -fLO https://raw.githubusercontent.com/ochenstarik-ui/lightweight-server/main/ochenstarik-server-monitor-manager.sh
|
||||
chmod 700 ochenstarik-server-monitor-manager.sh
|
||||
bash -n ochenstarik-server-monitor-manager.sh
|
||||
sudo ./ochenstarik-server-monitor-manager.sh hub
|
||||
```
|
||||
|
||||
Ouvrez UDP `51820` et TCP `7443` sur le Hub, créez les codes et installez les autres serveurs comme Nodes. Utilisez ensuite `install-control-hub`, `control-code`, `control-device-code` et `install-control-agent`. L'installateur choisit `amd64`/`arm64` et vérifie SHA-256.
|
||||
|
||||
## Sécurité et état
|
||||
|
||||
Aucun mot de passe root n'est partagé et la clé WireGuard privée reste sur le Node. Les identités monitoring, terminal, Agent, Operator et automatisation IA sont séparées. SSH emploie une forced-command sans shell/PTY/forwarding ; mTLS limite les rôles ; nftables n'autorise que les Links explicites ; SQLite enregistre état et audit avant le changement de pare-feu.
|
||||
|
||||
`v0.1.0-alpha.2` est destiné aux tests. Client Windows, installateur Hub/Node, Links, mTLS, SQLite, audit et événements sont disponibles. Restent le tampon hors ligne, la révocation des certificats, la réconciliation, le test de 50–100 Nodes et l'installateur Windows signé.
|
||||
|
||||
Documentation : [architecture](../architecture.md), [sécurité](../security-model.md), [roadmap](../roadmap.md), [installateur](../installer-contract.md).
|
||||
37
docs/i18n/README.hi.md
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# Server Monitor Manager
|
||||
|
||||
[English](../../README.md) · [Русский](README.ru.md) · [Español](README.es.md) · [简体中文](README.zh-CN.md) · **हिन्दी** · [العربية](README.ar.md) · [Português](README.pt-BR.md) · [Français](README.fr.md) · [Deutsch](README.de.md) · [日本語](README.ja.md) · [한국어](README.ko.md) · [Türkçe](README.tr.md)
|
||||
|
||||
Server Monitor Manager एक हल्का, Windows-first अनुप्रयोग है जो Linux सर्वरों की निगरानी, सीधे SSH सत्र और सर्वरों के बीच सुरक्षित कनेक्शन का स्पष्ट नियंत्रण देता है। यह निजी इंफ्रास्ट्रक्चर और छोटे server fleet के लिए है, जहाँ भारी dashboard, Kubernetes या हर Node पर public API की जरूरत नहीं होती।
|
||||
|
||||
## विशेषताएँ
|
||||
|
||||
- CPU/load, memory, swap, disk, inode, network, uptime, latency, SSH और WireGuard metrics;
|
||||
- profiles, groups, tags, favorites, alerts और छोटी local history;
|
||||
- अलग Ed25519 SSH key जिसकी private key Windows device पर रहती है;
|
||||
- Hub को private terminal key दिए बिना direct SSH terminal;
|
||||
- public IP वाले एक Hub से star network; Nodes को केवल outbound access चाहिए;
|
||||
- `AI agent → Home server:22` जैसे directional Links, जिन्हें अलग-अलग बंद किया जा सकता है;
|
||||
- `/32`, TCP/UDP port, policy version और TTL restrictions;
|
||||
- one-time enrollment, CSR, mTLS, role separation, idempotency और audit।
|
||||
|
||||
## संरचना और स्थापना
|
||||
|
||||
Windows client mTLS/HTTPS से ASP.NET Core 10 और SQLite Control Hub से जुड़ता है। Linux Agent केवल outbound session बनाता है। WireGuard data ले जाता है और Hub का nftables default रूप से transit रोकता है। Link केवल एक दिशा खोलता है; NAT के पीछे Home server को public IP नहीं चाहिए।
|
||||
|
||||
```bash
|
||||
curl -fLO https://raw.githubusercontent.com/ochenstarik-ui/lightweight-server/main/ochenstarik-server-monitor-manager.sh
|
||||
chmod 700 ochenstarik-server-monitor-manager.sh
|
||||
bash -n ochenstarik-server-monitor-manager.sh
|
||||
sudo ./ochenstarik-server-monitor-manager.sh hub
|
||||
```
|
||||
|
||||
Hub पर UDP `51820` और TCP `7443` खोलें, Node codes बनाएँ और दूसरे सर्वरों पर Node role चुनें। फिर `install-control-hub`, `control-code`, `control-device-code` और `install-control-agent` चलाएँ। Installer `amd64`/`arm64` चुनता है और SHA-256 जाँचता है।
|
||||
|
||||
## सुरक्षा और स्थिति
|
||||
|
||||
Shared root password नहीं है और Node की WireGuard private key Node से बाहर नहीं जाती। Monitoring, terminal, Agent, Operator और AI automation identities अलग हैं। SSH forced-command shell/PTY/forwarding नहीं देता; mTLS roles सीमित करता है; nftables केवल explicit Links स्वीकारता है; SQLite पहले desired state और audit सहेजता है।
|
||||
|
||||
`v0.1.0-alpha.2` testing release है। Windows client, Hub/Node installer, Links, mTLS, SQLite, audit और event stream तैयार हैं। Offline buffer, certificate revocation, reconnect reconciliation, 50–100 Node load test और signed Windows installer अभी बाकी हैं।
|
||||
|
||||
दस्तावेज़: [architecture](../architecture.md), [security](../security-model.md), [roadmap](../roadmap.md), [installer](../installer-contract.md)।
|
||||
37
docs/i18n/README.ja.md
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# Server Monitor Manager
|
||||
|
||||
[English](../../README.md) · [Русский](README.ru.md) · [Español](README.es.md) · [简体中文](README.zh-CN.md) · [हिन्दी](README.hi.md) · [العربية](README.ar.md) · [Português](README.pt-BR.md) · [Français](README.fr.md) · [Deutsch](README.de.md) · **日本語** · [한국어](README.ko.md) · [Türkçe](README.tr.md)
|
||||
|
||||
Server Monitor Manager は、Linux サーバーの監視、直接 SSH セッション、サーバー間の安全な接続を明示的に制御する軽量な Windows-first アプリケーションです。重い管理パネル、Kubernetes、各 Node の公開 API を必要としない個人インフラや小規模環境向けです。
|
||||
|
||||
## 主な機能
|
||||
|
||||
- CPU/load、メモリ、swap、ディスク、inode、ネットワーク、uptime、遅延、SSH、WireGuard の監視;
|
||||
- プロファイル、グループ、タグ、お気に入り、警告、短期ローカル履歴;
|
||||
- Windows 端末内に秘密鍵を保持する専用 Ed25519 SSH 鍵;
|
||||
- Hub に秘密鍵を渡さない直接 SSH ターミナル;
|
||||
- 公開 IP を持つ 1 台の Hub によるスター型ネットワーク。Node は外向き通信のみ;
|
||||
- `AI エージェント → Home server:22` のような方向付き Link を個別に無効化;
|
||||
- `/32`、TCP/UDP、ポート、ポリシーバージョン、TTL;
|
||||
- ワンタイム登録、CSR、mTLS、ロール分離、冪等性、監査。
|
||||
|
||||
## 構成とインストール
|
||||
|
||||
Windows クライアントは mTLS/HTTPS で ASP.NET Core 10 + SQLite Control Hub に接続します。Linux Agent は外向きセッションだけを作成します。WireGuard がデータを運び、Hub の nftables は転送を既定で拒否します。Link は一方向で、NAT 内の家庭サーバーに公開 IP は不要です。
|
||||
|
||||
```bash
|
||||
curl -fLO https://raw.githubusercontent.com/ochenstarik-ui/lightweight-server/main/ochenstarik-server-monitor-manager.sh
|
||||
chmod 700 ochenstarik-server-monitor-manager.sh
|
||||
bash -n ochenstarik-server-monitor-manager.sh
|
||||
sudo ./ochenstarik-server-monitor-manager.sh hub
|
||||
```
|
||||
|
||||
Hub で UDP `51820` と TCP `7443` を開き、Node コードを作成して他のサーバーを Node として導入します。その後 `install-control-hub`、`control-code`、`control-device-code`、`install-control-agent` を使用します。インストーラーは `amd64`/`arm64` を選択し SHA-256 を検証します。
|
||||
|
||||
## セキュリティと状況
|
||||
|
||||
共有 root パスワードはなく、Node の WireGuard 秘密鍵は Node 外に出ません。monitoring、terminal、Agent、Operator、AI automation の ID は分離されています。SSH は shell/PTY/forwarding のない forced-command、mTLS はロール制限、nftables は明示 Link のみを許可し、SQLite は firewall 変更前に状態と監査を保存します。
|
||||
|
||||
`v0.1.0-alpha.2` はテスト版です。Windows client、Hub/Node installer、Links、mTLS、SQLite、監査、イベントは実装済みです。オフラインバッファ、証明書失効、再接続調整、50–100 Node 負荷試験、署名付き Windows installer は今後の課題です。
|
||||
|
||||
資料:[アーキテクチャ](../architecture.md)、[セキュリティ](../security-model.md)、[ロードマップ](../roadmap.md)、[インストーラー](../installer-contract.md)。
|
||||
37
docs/i18n/README.ko.md
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# Server Monitor Manager
|
||||
|
||||
[English](../../README.md) · [Русский](README.ru.md) · [Español](README.es.md) · [简体中文](README.zh-CN.md) · [हिन्दी](README.hi.md) · [العربية](README.ar.md) · [Português](README.pt-BR.md) · [Français](README.fr.md) · [Deutsch](README.de.md) · [日本語](README.ja.md) · **한국어** · [Türkçe](README.tr.md)
|
||||
|
||||
Server Monitor Manager는 Linux 서버 모니터링, 직접 SSH 세션, 서버 간 보안 연결의 명시적 제어를 제공하는 가벼운 Windows 우선 애플리케이션입니다. 무거운 대시보드, Kubernetes 또는 각 Node의 공개 API가 필요 없는 개인 인프라와 소규모 서버 환경을 위한 도구입니다.
|
||||
|
||||
## 주요 기능
|
||||
|
||||
- CPU/load, 메모리, swap, 디스크, inode, 네트워크, uptime, 지연, SSH, WireGuard 모니터링;
|
||||
- 프로필, 그룹, 태그, 즐겨찾기, 경고와 짧은 로컬 기록;
|
||||
- 개인 키가 Windows 장치에만 남는 전용 Ed25519 SSH 키;
|
||||
- 개인 terminal 키를 Hub에 전달하지 않는 직접 SSH 터미널;
|
||||
- 공개 IP가 있는 하나의 Hub를 통한 스타 네트워크, Node는 outbound 연결만 필요;
|
||||
- `AI 에이전트 → 홈 서버:22` 같은 방향성 Link를 독립적으로 해제;
|
||||
- `/32`, TCP/UDP, 포트, 정책 버전, TTL 제한;
|
||||
- 일회용 등록, CSR, mTLS, 역할 분리, 멱등성 및 감사.
|
||||
|
||||
## 구조와 설치
|
||||
|
||||
Windows client는 mTLS/HTTPS로 ASP.NET Core 10 및 SQLite Control Hub에 연결합니다. Linux Agent는 outbound session만 만듭니다. WireGuard가 데이터를 전달하고 Hub의 nftables는 기본적으로 transit을 거부합니다. Link는 단방향이며 NAT 뒤의 홈 서버에는 공개 IP가 필요 없습니다.
|
||||
|
||||
```bash
|
||||
curl -fLO https://raw.githubusercontent.com/ochenstarik-ui/lightweight-server/main/ochenstarik-server-monitor-manager.sh
|
||||
chmod 700 ochenstarik-server-monitor-manager.sh
|
||||
bash -n ochenstarik-server-monitor-manager.sh
|
||||
sudo ./ochenstarik-server-monitor-manager.sh hub
|
||||
```
|
||||
|
||||
Hub에서 UDP `51820`과 TCP `7443`을 열고 Node 코드를 생성한 뒤 다른 서버를 Node로 설치합니다. 이후 `install-control-hub`, `control-code`, `control-device-code`, `install-control-agent`를 사용합니다. 설치 프로그램은 `amd64`/`arm64`를 선택하고 SHA-256을 검증합니다.
|
||||
|
||||
## 보안과 상태
|
||||
|
||||
공유 root 암호가 없고 Node WireGuard 개인 키는 Node를 떠나지 않습니다. monitoring, terminal, Agent, Operator, AI automation identity는 분리됩니다. SSH는 shell/PTY/forwarding 없는 forced-command를 사용하고, mTLS는 역할을 제한하며, nftables는 명시된 Link만 허용합니다. SQLite는 방화벽 변경 전에 상태와 감사를 저장합니다.
|
||||
|
||||
`v0.1.0-alpha.2`는 테스트 릴리스입니다. Windows client, Hub/Node installer, Links, mTLS, SQLite, 감사와 event stream이 구현되었습니다. offline buffer, 인증서 폐기, 재연결 조정, 50–100 Node 부하 시험과 서명된 Windows installer가 남아 있습니다.
|
||||
|
||||
문서: [아키텍처](../architecture.md), [보안](../security-model.md), [로드맵](../roadmap.md), [설치 계약](../installer-contract.md).
|
||||
37
docs/i18n/README.pt-BR.md
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# Server Monitor Manager
|
||||
|
||||
[English](../../README.md) · [Русский](README.ru.md) · [Español](README.es.md) · [简体中文](README.zh-CN.md) · [हिन्दी](README.hi.md) · [العربية](README.ar.md) · **Português** · [Français](README.fr.md) · [Deutsch](README.de.md) · [日本語](README.ja.md) · [한국어](README.ko.md) · [Türkçe](README.tr.md)
|
||||
|
||||
Server Monitor Manager é um aplicativo leve, inicialmente para Windows, que monitora servidores Linux, abre sessões SSH diretas e controla explicitamente conexões seguras entre servidores. Foi criado para infraestrutura pessoal e frotas pequenas sem painel pesado, Kubernetes ou API pública em cada Node.
|
||||
|
||||
## Recursos
|
||||
|
||||
- CPU/load, memória, swap, discos, inodes, rede, uptime, latência, SSH e WireGuard;
|
||||
- perfis, grupos, tags, favoritos, alertas e histórico local curto;
|
||||
- chave SSH Ed25519 dedicada, com a chave privada mantida no Windows;
|
||||
- terminal SSH direto sem entregar a chave privada ao Hub;
|
||||
- rede em estrela com um Hub de IP público; Nodes precisam apenas de acesso de saída;
|
||||
- Links direcionais como `agente de IA → servidor doméstico:22`, desligados separadamente;
|
||||
- políticas `/32`, TCP/UDP, porta, versão e TTL;
|
||||
- token único, CSR, mTLS, separação de papéis, idempotência e auditoria.
|
||||
|
||||
## Arquitetura e instalação
|
||||
|
||||
O cliente Windows usa mTLS/HTTPS com um Control Hub ASP.NET Core 10 e SQLite. O Linux Agent inicia somente sessões de saída. WireGuard transporta dados e nftables bloqueia trânsito por padrão. Um Link não abre o sentido inverso; um servidor doméstico atrás de NAT não precisa de IP público.
|
||||
|
||||
```bash
|
||||
curl -fLO https://raw.githubusercontent.com/ochenstarik-ui/lightweight-server/main/ochenstarik-server-monitor-manager.sh
|
||||
chmod 700 ochenstarik-server-monitor-manager.sh
|
||||
bash -n ochenstarik-server-monitor-manager.sh
|
||||
sudo ./ochenstarik-server-monitor-manager.sh hub
|
||||
```
|
||||
|
||||
Abra UDP `51820` e TCP `7443` no Hub, gere códigos e instale os demais servidores como Node. Depois use `install-control-hub`, `control-code`, `control-device-code` e `install-control-agent`. O instalador escolhe `amd64`/`arm64` e valida SHA-256.
|
||||
|
||||
## Segurança e status
|
||||
|
||||
Não há senha root compartilhada e a chave WireGuard privada nunca sai do Node. As identidades de monitoramento, terminal, Agent, Operator e automação de IA são separadas. SSH usa forced-command sem shell/PTY/forwarding; mTLS restringe funções; nftables permite apenas Links explícitos; SQLite registra estado e auditoria antes da mudança no firewall.
|
||||
|
||||
`v0.1.0-alpha.2` é uma versão de teste. Cliente Windows, instalador Hub/Node, Links, mTLS, SQLite, auditoria e eventos estão prontos. Restam buffer offline, revogação de certificado, reconciliação, teste com 50–100 Nodes e instalador Windows assinado.
|
||||
|
||||
Documentação: [arquitetura](../architecture.md), [segurança](../security-model.md), [roadmap](../roadmap.md), [instalador](../installer-contract.md).
|
||||
43
docs/i18n/README.ru.md
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
# Server Monitor Manager
|
||||
|
||||
[English](../../README.md) · **Русский** · [Español](README.es.md) · [简体中文](README.zh-CN.md) · [हिन्दी](README.hi.md) · [العربية](README.ar.md) · [Português](README.pt-BR.md) · [Français](README.fr.md) · [Deutsch](README.de.md) · [日本語](README.ja.md) · [한국어](README.ko.md) · [Türkçe](README.tr.md)
|
||||
|
||||
Server Monitor Manager — лёгкое Windows-приложение для мониторинга Linux-серверов, прямых SSH-сессий и явного управления защищёнными соединениями между серверами. Оно рассчитано на личную инфраструктуру и небольшие парки серверов без тяжёлой панели, Kubernetes или публичного API на каждом узле.
|
||||
|
||||
## Возможности
|
||||
|
||||
- мониторинг CPU/load, RAM, swap, дисков, inode, сети, uptime, задержки, SSH и WireGuard;
|
||||
- профили, группы, теги, избранное, предупреждения и короткая история метрик;
|
||||
- отдельный Ed25519 SSH-ключ, который остаётся на Windows-компьютере;
|
||||
- прямой SSH-терминал без передачи приватного terminal-ключа Hub-серверу;
|
||||
- объединение серверов через один Hub с белым IP; вторичным серверам нужен только исходящий доступ;
|
||||
- направленные Links, например `AI-агент → домашний сервер:22`, с независимым отключением;
|
||||
- ограничения по `/32`, TCP/UDP-порту, версии политики и TTL;
|
||||
- одноразовая регистрация, CSR, mTLS, разделение ролей, idempotency и аудит.
|
||||
|
||||
## Архитектура
|
||||
|
||||
Windows-клиент подключается по mTLS/HTTPS к Control Hub на ASP.NET Core 10 и SQLite. Linux Agent создаёт только исходящие mTLS-сессии. WireGuard образует звёздную сеть, а nftables на Hub по умолчанию запрещает транзит. Link направленный: доступ `AI-агент → Home` не открывает обратное направление или другие серверы. Домашнему серверу за NAT не нужен белый IP.
|
||||
|
||||
Control plane хранит inventory, метрики, политики, историю и аудит. Data plane состоит из WireGuard и постоянных nftables ACL. Operator-сертификат Windows защищён DPAPI; Agent доступен для `amd64` и `arm64` как самодостаточный Linux-бинарник.
|
||||
|
||||
## Быстрая установка
|
||||
|
||||
Установщик находится в [`ochenstarik-ui/lightweight-server`](https://github.com/ochenstarik-ui/lightweight-server):
|
||||
|
||||
```bash
|
||||
curl -fLO https://raw.githubusercontent.com/ochenstarik-ui/lightweight-server/main/ochenstarik-server-monitor-manager.sh
|
||||
chmod 700 ochenstarik-server-monitor-manager.sh
|
||||
bash -n ochenstarik-server-monitor-manager.sh
|
||||
sudo ./ochenstarik-server-monitor-manager.sh hub
|
||||
```
|
||||
|
||||
На Hub откройте UDP `51820` и TCP `7443`, создайте `node-code`, затем установите вторичные серверы в роли Node. Для постоянного слоя выполните `install-control-hub`, создайте `control-code`/`control-device-code`, а на Node — `install-control-agent`. Архитектура выбирается автоматически, SHA-256 проверяется.
|
||||
|
||||
## Безопасность и статус
|
||||
|
||||
Нет общих root-паролей; приватный WireGuard-ключ Node не покидает Node. Идентичности monitoring, terminal, Agent, Operator и AI-автоматизации разделены. Monitoring SSH использует forced-command без shell/PTY/forwarding. Agent может отправлять heartbeat только своего узла, Operator управляет inventory и Links. Отключение Link сначала сохраняется в SQLite, затем удаляет разрешение nftables; повтор запроса не повторяет побочный эффект.
|
||||
|
||||
`v0.1.0-alpha.2` предназначен для тестирования. Уже готовы Windows SSH-monitoring, Hub/Node installer, Links, mTLS, SQLite, аудит и поток событий. Остались offline-буфер Agent, отзыв сертификатов, reconnect reconciliation, нагрузочный тест 50–100 Node и подписанный Windows installer. До стабильного релиза используйте тестовые или резервируемые серверы.
|
||||
|
||||
Документы: [архитектура](../architecture.md), [безопасность](../security-model.md), [roadmap](../roadmap.md), [контракт установщика](../installer-contract.md).
|
||||
37
docs/i18n/README.tr.md
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# Server Monitor Manager
|
||||
|
||||
[English](../../README.md) · [Русский](README.ru.md) · [Español](README.es.md) · [简体中文](README.zh-CN.md) · [हिन्दी](README.hi.md) · [العربية](README.ar.md) · [Português](README.pt-BR.md) · [Français](README.fr.md) · [Deutsch](README.de.md) · [日本語](README.ja.md) · [한국어](README.ko.md) · **Türkçe**
|
||||
|
||||
Server Monitor Manager, Linux sunucularını izleyen, doğrudan SSH oturumları açan ve sunucular arasındaki güvenli bağlantıları açıkça yöneten hafif, Windows öncelikli bir uygulamadır. Ağır panel, Kubernetes veya her Node üzerinde herkese açık API gerektirmeyen kişisel altyapılar ve küçük filolar için tasarlanmıştır.
|
||||
|
||||
## Özellikler
|
||||
|
||||
- CPU/load, bellek, swap, disk, inode, ağ, uptime, gecikme, SSH ve WireGuard izleme;
|
||||
- profiller, gruplar, etiketler, favoriler, uyarılar ve kısa yerel geçmiş;
|
||||
- özel anahtarı Windows cihazında kalan ayrı Ed25519 SSH anahtarı;
|
||||
- özel terminal anahtarını Hub'a vermeden doğrudan SSH terminali;
|
||||
- genel IP'li tek Hub üzerinden yıldız ağ; Nodes yalnızca dış bağlantıya ihtiyaç duyar;
|
||||
- `AI ajanı → ev sunucusu:22` gibi bağımsız kapatılabilen yönlü Links;
|
||||
- `/32`, TCP/UDP, port, politika sürümü ve TTL sınırları;
|
||||
- tek kullanımlık kayıt, CSR, mTLS, rol ayrımı, idempotency ve audit.
|
||||
|
||||
## Mimari ve kurulum
|
||||
|
||||
Windows istemcisi mTLS/HTTPS ile ASP.NET Core 10 ve SQLite Control Hub'a bağlanır. Linux Agent yalnızca dış oturum açar. WireGuard veriyi taşır, Hub üzerindeki nftables varsayılan olarak geçişi engeller. Link tek yönlüdür; NAT arkasındaki ev sunucusunun genel IP'ye ihtiyacı yoktur.
|
||||
|
||||
```bash
|
||||
curl -fLO https://raw.githubusercontent.com/ochenstarik-ui/lightweight-server/main/ochenstarik-server-monitor-manager.sh
|
||||
chmod 700 ochenstarik-server-monitor-manager.sh
|
||||
bash -n ochenstarik-server-monitor-manager.sh
|
||||
sudo ./ochenstarik-server-monitor-manager.sh hub
|
||||
```
|
||||
|
||||
Hub üzerinde UDP `51820` ve TCP `7443` açın, Node kodlarını üretin ve diğer sunucuları Node olarak kurun. Ardından `install-control-hub`, `control-code`, `control-device-code` ve `install-control-agent` kullanın. Kurucu `amd64`/`arm64` seçer ve SHA-256 doğrular.
|
||||
|
||||
## Güvenlik ve durum
|
||||
|
||||
Ortak root parolası yoktur ve Node'un WireGuard özel anahtarı Node'dan çıkmaz. Monitoring, terminal, Agent, Operator ve AI automation kimlikleri ayrıdır. SSH shell/PTY/forwarding vermeyen forced-command kullanır; mTLS rolleri sınırlar; nftables yalnızca açık Links'e izin verir; SQLite güvenlik duvarı değişmeden önce durum ve audit kaydeder.
|
||||
|
||||
`v0.1.0-alpha.2` test sürümüdür. Windows client, Hub/Node installer, Links, mTLS, SQLite, audit ve event stream hazırdır. Offline buffer, sertifika iptali, yeniden bağlantı uzlaştırması, 50–100 Node yük testi ve imzalı Windows installer sıradadır.
|
||||
|
||||
Belgeler: [mimari](../architecture.md), [güvenlik](../security-model.md), [yol haritası](../roadmap.md), [kurucu sözleşmesi](../installer-contract.md).
|
||||
39
docs/i18n/README.zh-CN.md
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
# Server Monitor Manager
|
||||
|
||||
[English](../../README.md) · [Русский](README.ru.md) · [Español](README.es.md) · **简体中文** · [हिन्दी](README.hi.md) · [العربية](README.ar.md) · [Português](README.pt-BR.md) · [Français](README.fr.md) · [Deutsch](README.de.md) · [日本語](README.ja.md) · [한국어](README.ko.md) · [Türkçe](README.tr.md)
|
||||
|
||||
Server Monitor Manager 是一款轻量级、Windows 优先的 Linux 服务器监控与管理应用。它支持直接 SSH 会话,并能明确控制服务器之间的安全连接,适合个人基础设施和小型服务器集群,无需笨重面板、Kubernetes,也无需在每个节点暴露公共 API。
|
||||
|
||||
## 主要功能
|
||||
|
||||
- 监控 CPU/load、内存、swap、磁盘、inode、网络、uptime、延迟、SSH 和 WireGuard;
|
||||
- 管理配置、分组、标签、收藏、告警和短期本地历史;
|
||||
- 生成专用 Ed25519 SSH 密钥,私钥仅保存在 Windows 设备;
|
||||
- 直接 SSH 终端,不向 Hub 传输终端私钥;
|
||||
- 通过一个具有公网 IP 的 Hub 连接服务器,其他 Node 只需出站访问;
|
||||
- 创建 `AI 代理 → 家庭服务器:22` 等单向 Link,并可单独关闭;
|
||||
- 支持 `/32`、TCP/UDP、端口、策略版本和 TTL;
|
||||
- 一次性令牌、CSR、mTLS、角色隔离、幂等和审计。
|
||||
|
||||
## 架构
|
||||
|
||||
Windows 客户端通过 mTLS/HTTPS 连接 ASP.NET Core 10 + SQLite Control Hub。Linux Agent 只建立出站会话。WireGuard 负责数据传输,Hub 上的 nftables 默认拒绝节点间转发。Link 是单向的,不自动开放反向或其他服务器访问;NAT 后的家庭服务器无需公网 IP。
|
||||
|
||||
## 快速安装
|
||||
|
||||
```bash
|
||||
curl -fLO https://raw.githubusercontent.com/ochenstarik-ui/lightweight-server/main/ochenstarik-server-monitor-manager.sh
|
||||
chmod 700 ochenstarik-server-monitor-manager.sh
|
||||
bash -n ochenstarik-server-monitor-manager.sh
|
||||
sudo ./ochenstarik-server-monitor-manager.sh hub
|
||||
```
|
||||
|
||||
在 Hub 开放 UDP `51820` 和 TCP `7443`,创建 Node 注册码并在其他服务器选择 Node 角色。随后运行 `install-control-hub`、`control-code`、`control-device-code` 和 `install-control-agent`。安装器会自动选择 `amd64`/`arm64` 并校验 SHA-256。
|
||||
|
||||
## 安全与状态
|
||||
|
||||
系统不共享 root 密码,Node 的 WireGuard 私钥不会离开本机。监控、终端、Agent、Operator 和 AI 自动化身份相互隔离。SSH 使用无 shell、PTY、转发权限的 forced-command;mTLS 限制角色;nftables 仅允许明确 Link;SQLite 在执行防火墙变更前保存目标状态和审计。
|
||||
|
||||
`v0.1.0-alpha.2` 是测试版,已包含 Windows 客户端、Hub/Node 安装器、Links、mTLS、SQLite、审计和事件流。待完成:离线缓冲、证书撤销、重连协调、50–100 Node 压测和签名 Windows 安装器。
|
||||
|
||||
文档:[架构](../architecture.md)、[安全模型](../security-model.md)、[路线图](../roadmap.md)、[安装器协议](../installer-contract.md)。
|
||||
117
docs/installer-contract.md
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
# Контракт Linux-установщика
|
||||
|
||||
Единственный исходный файл `ochenstarik-server-monitor-manager.sh` хранится в `ochenstarik-ui/lightweight-server`. В репозитории desktop client не должна находиться устаревающая копия.
|
||||
|
||||
## Поддерживаемые роли
|
||||
|
||||
### Monitor only
|
||||
|
||||
Режим `install` устанавливает SSH monitoring endpoint без WireGuard:
|
||||
|
||||
- Ubuntu/Debian с systemd;
|
||||
- публичный ключ `ssh-ed25519` из Windows-клиента;
|
||||
- отдельный системный пользователь `ochenstarik-monitor` без пароля;
|
||||
- root-owned forced-command;
|
||||
- сохранение существующего SSH-порта;
|
||||
- отсутствие нового публичного API.
|
||||
|
||||
### Hub
|
||||
|
||||
Режим `hub` дополнительно:
|
||||
|
||||
- устанавливает WireGuard и nftables;
|
||||
- запрашивает публичный IPv4/домен и UDP-порт;
|
||||
- создаёт `smm0` с адресом `10.77.0.1/24`;
|
||||
- включает IPv4 forwarding;
|
||||
- устанавливает минимальный root helper и systemd restore unit;
|
||||
- хранит публичные identities Node и политики Links;
|
||||
- разрешает транзит только по явной политике.
|
||||
|
||||
### Node
|
||||
|
||||
Режим `node`:
|
||||
|
||||
- локально генерирует WireGuard keypair;
|
||||
- принимает одноразовый enrollment token;
|
||||
- отправляет Hub только публичный ключ;
|
||||
- получает внутренний адрес и аутентифицированное подтверждение конфигурации;
|
||||
- создаёт только исходящее WireGuard-соединение;
|
||||
- не требует белого IP или входящего публичного порта.
|
||||
|
||||
## Постоянный control layer (alpha)
|
||||
|
||||
После установки существующих ролей отдельные действия добавляют постоянные сервисы:
|
||||
|
||||
- `install-control-hub` скачивает release-архив под amd64/arm64, проверяет SHA-256, создаёт локальный CA, HTTPS-сертификат Hub, SQLite-каталог и изолированный systemd service;
|
||||
- `control-code NAME` создаёт десятиминутный token и код `SMMCTL1`, содержащий URL Hub и только публичный CA;
|
||||
- `control-device-code DEVICE` создаёт отдельный код `SMMDEV1` для operator identity Windows-клиента;
|
||||
- `install-control-agent` проверяет CA, локально создаёт ключ и CSR, регистрирует сертификат и запускает исходящий mTLS Agent через systemd.
|
||||
|
||||
Приватный ключ Control CA не включается в `SMMCTL1`, а приватный ключ Agent не покидает Node. По умолчанию Control Hub слушает TCP `7443`.
|
||||
|
||||
Control service не получает общий доступ к root helper. Отдельный root-owned wrapper принимает только проверенные `link-connect` и `link-disconnect`; команды регистрации, удаления Node и произвольные аргументы ему недоступны.
|
||||
|
||||
## Команды жизненного цикла
|
||||
|
||||
Целевой интерфейс:
|
||||
|
||||
```text
|
||||
install-monitor
|
||||
install-hub
|
||||
install-node
|
||||
install-control-hub
|
||||
install-control-agent
|
||||
control-code NAME
|
||||
control-device-code DEVICE
|
||||
status
|
||||
update
|
||||
rollback
|
||||
uninstall-monitor
|
||||
uninstall-node
|
||||
uninstall-hub
|
||||
```
|
||||
|
||||
Каждая установка и обновление должны быть идемпотентными. Перед изменением рабочей конфигурации создаётся root-only backup. Ошибка проверки или запуска автоматически восстанавливает последнюю рабочую версию.
|
||||
|
||||
Удаление роли должно убрать только принадлежащие ей файлы, units, интерфейсы и правила. Удаление Hub требует отдельного подтверждения и не должно молча оставлять включённый forwarding или nftables ACL.
|
||||
|
||||
## Forced-command
|
||||
|
||||
Ключ мониторинга допускает только:
|
||||
|
||||
- `metrics`;
|
||||
- read-only `mesh nodes`, `mesh links`, `mesh status` на Hub;
|
||||
- строго типизированные изменения Link с проверкой параметров.
|
||||
|
||||
Он не должен позволять shell, PTY, agent forwarding, TCP forwarding или произвольную команду. Полный SSH-терминал использует отдельную identity.
|
||||
|
||||
## Минимальный metrics snapshot
|
||||
|
||||
```text
|
||||
PROTOCOL=1
|
||||
HOSTNAME=server-name
|
||||
UPTIME_SECONDS=12345
|
||||
LOAD1=0.42
|
||||
CPU_COUNT=4
|
||||
MEM_TOTAL_KB=...
|
||||
MEM_AVAILABLE_KB=...
|
||||
SWAP_TOTAL_KB=...
|
||||
SWAP_FREE_KB=...
|
||||
DISK_TOTAL_KB=...
|
||||
DISK_AVAILABLE_KB=...
|
||||
DISK_INODES_TOTAL=...
|
||||
DISK_INODES_FREE=...
|
||||
NETWORK_RX_BYTES=...
|
||||
NETWORK_TX_BYTES=...
|
||||
KERNEL=...
|
||||
```
|
||||
|
||||
## Проверки перед применением
|
||||
|
||||
- `bash -n` и ShellCheck;
|
||||
- проверка SSH-ключа через `ssh-keygen`;
|
||||
- `sshd -t` перед reload;
|
||||
- `wg-quick strip` и пробный запуск конфигурации;
|
||||
- `nft --check` перед заменой таблицы;
|
||||
- проверка systemd unit;
|
||||
- сохранение активной SSH-сессии до подтверждения нового доступа.
|
||||
87
docs/roadmap.md
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
# План разработки
|
||||
|
||||
## Этап 0 — репозиторий и контракт
|
||||
|
||||
- [x] переименовать проект в Server Monitor Manager;
|
||||
- [x] выбрать звёздную архитектуру Hub/Node для первого Mesh;
|
||||
- [x] разделить monitoring identity, terminal identity и AI-agent identity;
|
||||
- [x] описать направленные Links и kill switch;
|
||||
- [ ] выбрать лицензию;
|
||||
- [ ] добавить CI, форматирование, тесты и release checksum;
|
||||
- [ ] объединить PR приложения и установщика в `main`.
|
||||
|
||||
## Этап 1 — Windows SSH MVP
|
||||
|
||||
- [x] создать packaged WinUI 3 приложение;
|
||||
- [x] добавить адаптивный Overview и профили нескольких серверов;
|
||||
- [x] генерировать отдельный Ed25519 SSH-ключ;
|
||||
- [x] сохранять профили локально без паролей;
|
||||
- [x] получать CPU/load, RAM, disk, uptime и latency;
|
||||
- [x] собрать и реально запустить x64-приложение;
|
||||
- [x] редактирование и удаление серверов;
|
||||
- [x] единственный изменяемый Hub;
|
||||
- [ ] настоящие страницы Servers, Links, Sessions и Settings.
|
||||
|
||||
## Этап 2 — установщик Hub/Node
|
||||
|
||||
- [x] SSH forced-command для Ubuntu/Debian;
|
||||
- [x] роли `hub` и `node`;
|
||||
- [x] WireGuard Hub и исходящие Node-соединения;
|
||||
- [x] постоянный nftables ACL на Hub;
|
||||
- [x] список узлов и handshake-состояние;
|
||||
- [x] явные зависимости `sudo`, `visudo` и `ping` для минимального Debian;
|
||||
- [x] безопасные `update` и `rollback` с root-only backup;
|
||||
- [x] полный `uninstall-monitor`, `uninstall-node` и `uninstall-hub`;
|
||||
- [ ] интеграционный тест повторной установки и reboot.
|
||||
|
||||
## Этап 3 — безопасная регистрация
|
||||
|
||||
- [x] локальная генерация WireGuard-ключа на Node;
|
||||
- [x] одноразовый enrollment token;
|
||||
- [x] срок действия не более 10 минут;
|
||||
- [x] атомарное погашение token;
|
||||
- [ ] отзыв и повторная регистрация Node;
|
||||
- [x] подтверждение SHA-256 fingerprint Control CA Hub;
|
||||
- [x] защита desktop SSH-ключа через DPAPI.
|
||||
|
||||
## Этап 4 — управляемые Links
|
||||
|
||||
- [x] направленные пары source → destination;
|
||||
- [x] ручное добавление и удаление nftables ACL из Windows-клиента;
|
||||
- [x] политики по целевому `/32`, TCP/UDP и порту;
|
||||
- [x] TTL и автоматическое истечение;
|
||||
- [ ] состояния Connecting, Active, Disconnecting, Partial, Disabled и Failed;
|
||||
- [x] версия политики и подтверждение применения на Hub;
|
||||
- [ ] обязательное отключение после reconnect;
|
||||
- [x] локальный append-only JSONL-аудит операций Link;
|
||||
- [ ] интеграционные тесты kill switch и частичных отказов.
|
||||
|
||||
## Этап 5 — мониторинг и терминал
|
||||
|
||||
- [x] swap, inode, network и состояние SSH/WireGuard;
|
||||
- [x] предупреждения по диску, памяти, inode и недоступности;
|
||||
- [x] автоматическое обновление каждые 30 секунд;
|
||||
- [x] короткая локальная история до 240 точек на сервер;
|
||||
- [x] встроенный график CPU, RAM и диска;
|
||||
- [ ] экспорт диагностики без секретов;
|
||||
- [x] отдельный прямой SSH-терминал;
|
||||
- [x] отдельная terminal identity и подтверждение пользователя;
|
||||
- [ ] отдельная automation identity для AI-агента.
|
||||
|
||||
## Этап 6 — постоянный control layer
|
||||
|
||||
- [x] самодостаточный single-file Linux agent для amd64/arm64;
|
||||
- [x] SQLite inventory, policies, history и audit;
|
||||
- [x] исходящие mTLS agent sessions;
|
||||
- [x] защищённый Hub event stream для desktop client;
|
||||
- [ ] ограниченный локальный буфер и downsampling;
|
||||
- [x] idempotency key и защита от replay;
|
||||
- [ ] тест нагрузки 50–100 Node на одном Hub.
|
||||
|
||||
## Этап 7 — релиз и другие платформы
|
||||
|
||||
- [ ] подписанный Windows installer и GitHub Release;
|
||||
- [ ] checksum Linux-установщика и бинарников;
|
||||
- [ ] macOS и Linux desktop после стабилизации Core/API;
|
||||
- [ ] Android/iOS companion clients;
|
||||
- [ ] push-уведомления без административных секретов у push-провайдера.
|
||||
71
docs/security-model.md
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
# Модель безопасности
|
||||
|
||||
## Защищаемые данные
|
||||
|
||||
- SSH, WireGuard, device и agent identities;
|
||||
- topology, внутренние адреса, метрики и состояния Links;
|
||||
- команды, терминальные сессии и аудит;
|
||||
- права пользователей и AI-агентов.
|
||||
|
||||
## Границы доверия
|
||||
|
||||
Windows client, Hub и каждый Node считаются отдельными субъектами. Компрометация одного Node не должна выдавать ключи других узлов или право создавать новые Links.
|
||||
|
||||
Hub хранит только публичные WireGuard-ключи Node. Приватный ключ Node генерируется локально, не включается в enrollment token и не записывается на Hub.
|
||||
|
||||
## SSH monitoring MVP
|
||||
|
||||
Windows-клиент использует отдельный Ed25519-ключ только для forced-command. Этот ключ не используется для интерактивного терминала или AI-агента. Первый host key требует явного подтверждения fingerprint; последующие подключения проверяют сохранённый `known_hosts`.
|
||||
|
||||
Приватный ключ клиента защищается Windows DPAPI и доступом текущего пользователя. Профили серверов не содержат паролей.
|
||||
|
||||
## Регистрация Node
|
||||
|
||||
1. На Hub создаётся случайный enrollment token со сроком жизни не более 10 минут.
|
||||
2. Node локально генерирует WireGuard-ключ.
|
||||
3. Node предъявляет token и публичный ключ по защищённому каналу.
|
||||
4. Hub атомарно помечает token использованным, регистрирует адрес и возвращает подтверждение, которое связывает token, публичный ключ и все параметры конфигурации.
|
||||
5. Повторное использование, просроченный token и смена публичного ключа отклоняются.
|
||||
|
||||
До появления mTLS enrollment выполняется через отдельную ограниченную SSH-команду. Token не должен содержать приватный WireGuard-ключ.
|
||||
|
||||
## Operator identity Windows-клиента
|
||||
|
||||
Windows-клиент получает отдельный код `SMMDEV1`, показывает URL и SHA-256 fingerprint Control CA и продолжает регистрацию только после явного подтверждения пользователя. Приватный operator key создаётся локально, хранится в DPAPI current-user scope и не используется как SSH monitoring, terminal или Agent identity.
|
||||
|
||||
Сертификат `Agent` разрешает только heartbeat собственного `node_id`. Только сертификат с ролью `Operator` может читать общий inventory, изменять Links и подписываться на event stream. Control service вызывает от root только отдельный wrapper с командами `link-connect` и `link-disconnect`; другие Hub-команды через него запрещены.
|
||||
|
||||
## Авторизация Links
|
||||
|
||||
- мониторинг read-only отделён от управления;
|
||||
- Link всегда содержит source, destination, CIDR, протокол и порт;
|
||||
- пустой список портов не означает разрешение всех портов;
|
||||
- automation identity не наследует интерактивные права владельца;
|
||||
- команды имеют TTL, nonce/idempotency key и версию политики;
|
||||
- Hub проверяет имена и параметры повторно, независимо от проверки desktop client.
|
||||
|
||||
## Аудит
|
||||
|
||||
Записываются регистрация и удаление узла, изменение прав, создание, включение, истечение и отключение Link, а также ошибки применения. Секреты, приватные ключи и полный ввод терминала не журналируются.
|
||||
|
||||
Для первого MVP используется append-only JSONL с ограниченными правами и ротацией. После появления control service аудит переносится в SQLite с возможностью внешнего экспорта.
|
||||
|
||||
## Kill switch
|
||||
|
||||
Отключение Link должно:
|
||||
|
||||
1. немедленно удалить разрешающее правило на Hub;
|
||||
2. записать желаемое состояние `Disabled` до отправки дополнительных команд;
|
||||
3. запретить автоматическое восстановление после reboot/reconnect;
|
||||
4. получить подтверждение применённой версии политики;
|
||||
5. показывать `Partial`, если подтверждение одного из узлов отсутствует;
|
||||
6. сохранить обязательную операцию для временно недоступного Node.
|
||||
|
||||
## Не входит в первый MVP
|
||||
|
||||
- выполнение произвольных root-команд;
|
||||
- хранение пользовательских приватных SSH-ключей на Hub;
|
||||
- автоматическое объединение всех серверов в flat network;
|
||||
- публичный входящий API на Node;
|
||||
- собственная криптография вместо SSH, TLS и WireGuard;
|
||||
- автоматический failover между несколькими Hub.
|
||||
105
src/ServerMonitorManager.Agent/AgentClient.cs
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
using System.Net.Http.Json;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using ServerMonitorManager.Core;
|
||||
|
||||
namespace ServerMonitorManager.Agent;
|
||||
|
||||
internal sealed class AgentClient(AgentOptions options)
|
||||
{
|
||||
private readonly string _certificatePath = Path.Combine(options.StateDirectory, "agent.pfx");
|
||||
|
||||
public async Task EnrollAsync(string token, CancellationToken cancellationToken)
|
||||
{
|
||||
Directory.CreateDirectory(options.StateDirectory);
|
||||
using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var request = new CertificateRequest(
|
||||
$"CN={options.NodeId}",
|
||||
key,
|
||||
HashAlgorithmName.SHA256);
|
||||
var enrollment = new EnrollmentRequest(
|
||||
options.NodeId,
|
||||
token,
|
||||
request.CreateSigningRequestPem(),
|
||||
Guid.NewGuid().ToString());
|
||||
|
||||
using var client = CreateHttpClient(clientCertificate: null);
|
||||
using var response = await client.PostAsJsonAsync(
|
||||
"api/v1/enroll",
|
||||
enrollment,
|
||||
SmmJsonContext.Default.EnrollmentRequest,
|
||||
cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var result = await response.Content.ReadFromJsonAsync(
|
||||
SmmJsonContext.Default.EnrollmentResponse,
|
||||
cancellationToken)
|
||||
?? throw new InvalidOperationException("Control service returned an empty enrollment response.");
|
||||
using var certificate = X509Certificate2.CreateFromPem(result.CertificatePem, key.ExportPkcs8PrivateKeyPem());
|
||||
await File.WriteAllBytesAsync(_certificatePath, certificate.Export(X509ContentType.Pfx), cancellationToken);
|
||||
await File.WriteAllTextAsync(options.CertificateAuthorityPath, result.CertificateAuthorityPem, cancellationToken);
|
||||
SetOwnerOnlyPermissions(_certificatePath);
|
||||
SetOwnerOnlyPermissions(options.CertificateAuthorityPath);
|
||||
}
|
||||
|
||||
public async Task RunAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
using var certificate = X509CertificateLoader.LoadPkcs12FromFile(
|
||||
_certificatePath,
|
||||
password: null,
|
||||
X509KeyStorageFlags.EphemeralKeySet);
|
||||
using var client = CreateHttpClient(certificate);
|
||||
var delay = TimeSpan.FromSeconds(options.HeartbeatSeconds);
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
var heartbeat = LinuxMetrics.Collect(options.NodeId, "0.1.0");
|
||||
using var response = await client.PostAsJsonAsync(
|
||||
"api/v1/agents/heartbeat",
|
||||
heartbeat,
|
||||
SmmJsonContext.Default.AgentHeartbeat,
|
||||
cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var accepted = await response.Content.ReadFromJsonAsync(
|
||||
SmmJsonContext.Default.AgentHeartbeatResponse,
|
||||
cancellationToken)
|
||||
?? throw new InvalidOperationException("Control service returned an empty heartbeat response.");
|
||||
delay = TimeSpan.FromSeconds(Math.Clamp(accepted.NextHeartbeatSeconds, 10, 300));
|
||||
await Task.Delay(delay, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private HttpClient CreateHttpClient(X509Certificate2? clientCertificate)
|
||||
{
|
||||
using var root = X509CertificateLoader.LoadCertificateFromFile(options.CertificateAuthorityPath);
|
||||
var rootBytes = root.Export(X509ContentType.Cert);
|
||||
var handler = new HttpClientHandler();
|
||||
if (clientCertificate is not null)
|
||||
{
|
||||
handler.ClientCertificates.Add(clientCertificate);
|
||||
}
|
||||
handler.ServerCertificateCustomValidationCallback = (_, certificate, _, errors) =>
|
||||
{
|
||||
if (certificate is null
|
||||
|| errors.HasFlag(System.Net.Security.SslPolicyErrors.RemoteCertificateNameMismatch)
|
||||
|| errors.HasFlag(System.Net.Security.SslPolicyErrors.RemoteCertificateNotAvailable))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
using var trustedRoot = X509CertificateLoader.LoadCertificate(rootBytes);
|
||||
using var chain = new X509Chain();
|
||||
chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
|
||||
chain.ChainPolicy.CustomTrustStore.Add(trustedRoot);
|
||||
chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
|
||||
chain.ChainPolicy.ApplicationPolicy.Add(new Oid("1.3.6.1.5.5.7.3.1"));
|
||||
return chain.Build(new X509Certificate2(certificate));
|
||||
};
|
||||
return new HttpClient(handler) { BaseAddress = options.ControlUrl };
|
||||
}
|
||||
|
||||
private static void SetOwnerOnlyPermissions(string path)
|
||||
{
|
||||
if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS())
|
||||
{
|
||||
File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite);
|
||||
}
|
||||
}
|
||||
}
|
||||
10
src/ServerMonitorManager.Agent/AgentOptions.cs
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
namespace ServerMonitorManager.Agent;
|
||||
|
||||
public sealed class AgentOptions
|
||||
{
|
||||
public string NodeId { get; init; } = Environment.MachineName.ToLowerInvariant();
|
||||
public Uri ControlUrl { get; init; } = new("https://127.0.0.1:7443");
|
||||
public string StateDirectory { get; init; } = "/var/lib/ochenstarik-server-monitor-manager/agent";
|
||||
public string CertificateAuthorityPath { get; init; } = "/etc/ochenstarik-server-monitor-manager/control-ca.crt";
|
||||
public int HeartbeatSeconds { get; init; } = 30;
|
||||
}
|
||||
78
src/ServerMonitorManager.Agent/LinuxMetrics.cs
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
using ServerMonitorManager.Core;
|
||||
|
||||
namespace ServerMonitorManager.Agent;
|
||||
|
||||
internal static class LinuxMetrics
|
||||
{
|
||||
public static AgentHeartbeat Collect(string nodeId, string version)
|
||||
{
|
||||
var memory = ReadMemory();
|
||||
var disk = new DriveInfo("/");
|
||||
var network = ReadNetwork();
|
||||
return new AgentHeartbeat(
|
||||
nodeId,
|
||||
version,
|
||||
DateTimeOffset.UtcNow,
|
||||
ReadLoadOne(),
|
||||
memory.Total - memory.Available,
|
||||
memory.Total,
|
||||
disk.TotalSize - disk.AvailableFreeSpace,
|
||||
disk.TotalSize,
|
||||
network.Receive,
|
||||
network.Transmit,
|
||||
ReadUptimeSeconds(),
|
||||
Guid.NewGuid().ToString());
|
||||
}
|
||||
|
||||
private static double ReadLoadOne()
|
||||
{
|
||||
var value = File.ReadAllText("/proc/loadavg").Split(' ', 2)[0];
|
||||
return double.Parse(value, System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static long ReadUptimeSeconds()
|
||||
{
|
||||
var value = File.ReadAllText("/proc/uptime").Split(' ', 2)[0];
|
||||
return (long)double.Parse(value, System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static (long Total, long Available) ReadMemory()
|
||||
{
|
||||
long total = 0;
|
||||
long available = 0;
|
||||
foreach (var line in File.ReadLines("/proc/meminfo"))
|
||||
{
|
||||
var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length < 2)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (parts[0] == "MemTotal:")
|
||||
{
|
||||
total = long.Parse(parts[1]) * 1024;
|
||||
}
|
||||
else if (parts[0] == "MemAvailable:")
|
||||
{
|
||||
available = long.Parse(parts[1]) * 1024;
|
||||
}
|
||||
}
|
||||
return (total, available);
|
||||
}
|
||||
|
||||
private static (long Receive, long Transmit) ReadNetwork()
|
||||
{
|
||||
long receive = 0;
|
||||
long transmit = 0;
|
||||
foreach (var line in File.ReadLines("/proc/net/dev").Skip(2))
|
||||
{
|
||||
var parts = line.Split([':', ' '], StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length < 10 || parts[0] == "lo")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
receive += long.Parse(parts[1]);
|
||||
transmit += long.Parse(parts[9]);
|
||||
}
|
||||
return (receive, transmit);
|
||||
}
|
||||
}
|
||||
33
src/ServerMonitorManager.Agent/Program.cs
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
using Microsoft.Extensions.Configuration;
|
||||
using ServerMonitorManager.Agent;
|
||||
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddJsonFile("appsettings.json", optional: true)
|
||||
.AddEnvironmentVariables("SMM_")
|
||||
.AddCommandLine(args)
|
||||
.Build();
|
||||
var options = configuration.Get<AgentOptions>() ?? new AgentOptions();
|
||||
if (string.IsNullOrWhiteSpace(options.NodeId)
|
||||
|| !options.NodeId.All(character => character is >= 'a' and <= 'z' or >= '0' and <= '9' or '-'))
|
||||
{
|
||||
Console.Error.WriteLine("NodeId must contain lowercase letters, digits, or hyphens.");
|
||||
return 2;
|
||||
}
|
||||
|
||||
using var shutdown = new CancellationTokenSource();
|
||||
Console.CancelKeyPress += (_, eventArgs) =>
|
||||
{
|
||||
eventArgs.Cancel = true;
|
||||
shutdown.Cancel();
|
||||
};
|
||||
var client = new AgentClient(options);
|
||||
var enrollmentToken = configuration["EnrollToken"];
|
||||
if (!string.IsNullOrWhiteSpace(enrollmentToken))
|
||||
{
|
||||
await client.EnrollAsync(enrollmentToken, shutdown.Token);
|
||||
Console.WriteLine("Agent enrollment completed.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
await client.RunAsync(shutdown.Token);
|
||||
return 0;
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<Version>0.1.0</Version>
|
||||
<AssemblyName>ochenstarik-smm-agent</AssemblyName>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ServerMonitorManager.Core\ServerMonitorManager.Core.csproj" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.10" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.CommandLine" Version="10.0.10" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.10" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.10" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Update="appsettings.json" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
7
src/ServerMonitorManager.Agent/appsettings.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"NodeId": "change-me",
|
||||
"ControlUrl": "https://127.0.0.1:7443",
|
||||
"StateDirectory": "/var/lib/ochenstarik-server-monitor-manager/agent",
|
||||
"CertificateAuthorityPath": "/etc/ochenstarik-server-monitor-manager/control-ca.crt",
|
||||
"HeartbeatSeconds": 30
|
||||
}
|
||||
75
src/ServerMonitorManager.Control/CertificateAuthority.cs
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ServerMonitorManager.Control;
|
||||
|
||||
public sealed class CertificateAuthority : IDisposable
|
||||
{
|
||||
private readonly X509Certificate2 _issuer;
|
||||
|
||||
public CertificateAuthority(IOptions<ControlOptions> options)
|
||||
{
|
||||
var value = options.Value;
|
||||
_issuer = X509CertificateLoader.LoadPkcs12FromFile(
|
||||
value.CertificateAuthorityPath,
|
||||
value.CertificateAuthorityPassword,
|
||||
X509KeyStorageFlags.EphemeralKeySet);
|
||||
if (!_issuer.HasPrivateKey)
|
||||
{
|
||||
throw new InvalidOperationException("Control CA certificate must contain its private key.");
|
||||
}
|
||||
}
|
||||
|
||||
public X509Certificate2 PublicCertificate => _issuer;
|
||||
|
||||
public IssuedCertificate IssueClientCertificate(string nodeId, string csrPem)
|
||||
{
|
||||
var request = CertificateRequest.LoadSigningRequestPem(
|
||||
csrPem,
|
||||
HashAlgorithmName.SHA256,
|
||||
CertificateRequestLoadOptions.UnsafeLoadCertificateExtensions);
|
||||
if (!string.Equals(request.SubjectName.Name, $"CN={nodeId}", StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException("CSR subject does not match the requested node id.");
|
||||
}
|
||||
if (request.CertificateExtensions.Count != 0)
|
||||
{
|
||||
throw new InvalidOperationException("CSR extensions are not accepted.");
|
||||
}
|
||||
|
||||
request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, true));
|
||||
request.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, true));
|
||||
request.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(
|
||||
new OidCollection { new("1.3.6.1.5.5.7.3.2") },
|
||||
true));
|
||||
request.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(request.PublicKey, false));
|
||||
|
||||
var serial = RandomNumberGenerator.GetBytes(20);
|
||||
var issuerNotBefore = new DateTimeOffset(_issuer.NotBefore.ToUniversalTime(), TimeSpan.Zero);
|
||||
var issuerNotAfter = new DateTimeOffset(_issuer.NotAfter.ToUniversalTime(), TimeSpan.Zero);
|
||||
var notBefore = DateTimeOffset.UtcNow.AddMinutes(-2) > issuerNotBefore
|
||||
? DateTimeOffset.UtcNow.AddMinutes(-2)
|
||||
: issuerNotBefore;
|
||||
var requestedNotAfter = DateTimeOffset.UtcNow.AddYears(1);
|
||||
var notAfter = requestedNotAfter < issuerNotAfter ? requestedNotAfter : issuerNotAfter;
|
||||
if (notAfter <= notBefore)
|
||||
{
|
||||
throw new InvalidOperationException("Control CA certificate is expired or not yet valid.");
|
||||
}
|
||||
using var certificate = request.Create(_issuer, notBefore, notAfter, serial);
|
||||
return new IssuedCertificate(
|
||||
certificate.ExportCertificatePem(),
|
||||
_issuer.ExportCertificatePem(),
|
||||
certificate.Thumbprint,
|
||||
notAfter);
|
||||
}
|
||||
|
||||
public void Dispose() => _issuer.Dispose();
|
||||
}
|
||||
|
||||
public sealed record IssuedCertificate(
|
||||
string CertificatePem,
|
||||
string CertificateAuthorityPem,
|
||||
string Thumbprint,
|
||||
DateTimeOffset ExpiresAt);
|
||||
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();
|
||||
}
|
||||
16
src/ServerMonitorManager.Control/ControlOptions.cs
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
namespace ServerMonitorManager.Control;
|
||||
|
||||
public sealed class ControlOptions
|
||||
{
|
||||
public const string SectionName = "Control";
|
||||
|
||||
public string DatabasePath { get; init; } = "/var/lib/ochenstarik-server-monitor-manager/control.db";
|
||||
|
||||
public string CertificateAuthorityPath { get; init; } = "/etc/ochenstarik-server-monitor-manager/control-ca.pfx";
|
||||
|
||||
public string? CertificateAuthorityPassword { get; init; }
|
||||
|
||||
public int HeartbeatSeconds { get; init; } = 30;
|
||||
|
||||
public string HubHelperPath { get; init; } = "/usr/local/libexec/ochenstarik-smm-policy-apply";
|
||||
}
|
||||
777
src/ServerMonitorManager.Control/ControlStore.cs
Normal file
|
|
@ -0,0 +1,777 @@
|
|||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ServerMonitorManager.Core;
|
||||
|
||||
namespace ServerMonitorManager.Control;
|
||||
|
||||
public sealed class ControlStore(IOptions<ControlOptions> options)
|
||||
{
|
||||
private readonly string _connectionString = new SqliteConnectionStringBuilder
|
||||
{
|
||||
DataSource = options.Value.DatabasePath,
|
||||
Mode = SqliteOpenMode.ReadWriteCreate,
|
||||
Cache = SqliteCacheMode.Shared
|
||||
}.ToString();
|
||||
|
||||
public async Task InitializeAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var path = new SqliteConnectionStringBuilder(_connectionString).DataSource;
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(path))!);
|
||||
await using var connection = await OpenAsync(cancellationToken);
|
||||
var command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA foreign_keys = ON;
|
||||
CREATE TABLE IF NOT EXISTS enrollment_tokens (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
node_id TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
consumed_at TEXT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS agents (
|
||||
node_id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
certificate_thumbprint TEXT NOT NULL UNIQUE,
|
||||
certificate_expires_at TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
agent_version TEXT NOT NULL DEFAULT '',
|
||||
last_seen_at TEXT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS device_tokens (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
device_id TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
consumed_at TEXT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS devices (
|
||||
device_id TEXT PRIMARY KEY,
|
||||
certificate_thumbprint TEXT NOT NULL UNIQUE,
|
||||
certificate_expires_at TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
last_seen_at TEXT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS metric_samples (
|
||||
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
node_id TEXT NOT NULL REFERENCES agents(node_id) ON DELETE CASCADE,
|
||||
recorded_at TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_metric_samples_node_time
|
||||
ON metric_samples(node_id, recorded_at DESC);
|
||||
CREATE TABLE IF NOT EXISTS idempotency (
|
||||
operation_key TEXT PRIMARY KEY,
|
||||
request_hash TEXT NOT NULL,
|
||||
response_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS audit (
|
||||
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
recorded_at TEXT NOT NULL,
|
||||
actor TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
details_json TEXT NOT NULL
|
||||
);
|
||||
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);
|
||||
}
|
||||
|
||||
public async Task<string> CreateEnrollmentTokenAsync(
|
||||
string nodeId,
|
||||
TimeSpan lifetime,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32))
|
||||
.TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||
await using var connection = await OpenAsync(cancellationToken);
|
||||
var command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
INSERT INTO enrollment_tokens(token_hash, node_id, expires_at)
|
||||
VALUES ($hash, $node, $expires);
|
||||
""";
|
||||
command.Parameters.AddWithValue("$hash", Hash(token));
|
||||
command.Parameters.AddWithValue("$node", nodeId);
|
||||
command.Parameters.AddWithValue("$expires", DateTimeOffset.UtcNow.Add(lifetime).ToString("O"));
|
||||
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||
return token;
|
||||
}
|
||||
|
||||
public async Task<string> CreateDeviceEnrollmentTokenAsync(
|
||||
string deviceId,
|
||||
TimeSpan lifetime,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32))
|
||||
.TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||
await using var connection = await OpenAsync(cancellationToken);
|
||||
var command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
INSERT INTO device_tokens(token_hash, device_id, expires_at)
|
||||
VALUES ($hash, $device, $expires);
|
||||
""";
|
||||
command.Parameters.AddWithValue("$hash", Hash(token));
|
||||
command.Parameters.AddWithValue("$device", deviceId);
|
||||
command.Parameters.AddWithValue("$expires", DateTimeOffset.UtcNow.Add(lifetime).ToString("O"));
|
||||
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||
return token;
|
||||
}
|
||||
|
||||
public async Task<DeviceEnrollmentResponse?> EnrollDeviceAsync(
|
||||
DeviceEnrollmentRequest request,
|
||||
Func<IssuedCertificate> issueCertificate,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = await OpenAsync(cancellationToken);
|
||||
await using var transaction = (SqliteTransaction)await connection.BeginTransactionAsync(cancellationToken);
|
||||
var cached = await ReadIdempotentAsync<DeviceEnrollmentResponse>(
|
||||
connection,
|
||||
transaction,
|
||||
$"device-enroll:{request.IdempotencyKey}",
|
||||
Fingerprint(request, SmmJsonContext.Default.DeviceEnrollmentRequest),
|
||||
SmmJsonContext.Default.DeviceEnrollmentResponse,
|
||||
cancellationToken);
|
||||
if (cached is not null)
|
||||
{
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return cached;
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow.ToString("O");
|
||||
var consume = connection.CreateCommand();
|
||||
consume.Transaction = transaction;
|
||||
consume.CommandText = """
|
||||
UPDATE device_tokens
|
||||
SET consumed_at = $now
|
||||
WHERE token_hash = $hash
|
||||
AND device_id = $device
|
||||
AND consumed_at IS NULL
|
||||
AND expires_at >= $now;
|
||||
""";
|
||||
consume.Parameters.AddWithValue("$now", now);
|
||||
consume.Parameters.AddWithValue("$hash", Hash(request.Token));
|
||||
consume.Parameters.AddWithValue("$device", request.DeviceId);
|
||||
if (await consume.ExecuteNonQueryAsync(cancellationToken) != 1)
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
return null;
|
||||
}
|
||||
|
||||
var issued = issueCertificate();
|
||||
var response = new DeviceEnrollmentResponse(
|
||||
request.DeviceId,
|
||||
issued.CertificatePem,
|
||||
issued.CertificateAuthorityPem,
|
||||
issued.ExpiresAt);
|
||||
var upsert = connection.CreateCommand();
|
||||
upsert.Transaction = transaction;
|
||||
upsert.CommandText = """
|
||||
INSERT INTO devices(device_id, certificate_thumbprint, certificate_expires_at, status)
|
||||
VALUES ($device, $thumbprint, $expires, 'Active')
|
||||
ON CONFLICT(device_id) DO UPDATE SET
|
||||
certificate_thumbprint = excluded.certificate_thumbprint,
|
||||
certificate_expires_at = excluded.certificate_expires_at,
|
||||
status = 'Active';
|
||||
""";
|
||||
upsert.Parameters.AddWithValue("$device", request.DeviceId);
|
||||
upsert.Parameters.AddWithValue("$thumbprint", issued.Thumbprint);
|
||||
upsert.Parameters.AddWithValue("$expires", issued.ExpiresAt.ToString("O"));
|
||||
await upsert.ExecuteNonQueryAsync(cancellationToken);
|
||||
await WriteIdempotentAsync(
|
||||
connection,
|
||||
transaction,
|
||||
$"device-enroll:{request.IdempotencyKey}",
|
||||
Fingerprint(request, SmmJsonContext.Default.DeviceEnrollmentRequest),
|
||||
response,
|
||||
SmmJsonContext.Default.DeviceEnrollmentResponse,
|
||||
cancellationToken);
|
||||
await WriteAuditAsync(
|
||||
connection, transaction, request.DeviceId, "device.enroll", request.DeviceId, "{}", cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return response;
|
||||
}
|
||||
|
||||
public async Task<EnrollmentResponse?> EnrollAsync(
|
||||
EnrollmentRequest request,
|
||||
Func<IssuedCertificate> issueCertificate,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = await OpenAsync(cancellationToken);
|
||||
await using var transaction = (SqliteTransaction)await connection.BeginTransactionAsync(cancellationToken);
|
||||
|
||||
var cached = await ReadIdempotentAsync<EnrollmentResponse>(
|
||||
connection,
|
||||
transaction,
|
||||
$"enroll:{request.IdempotencyKey}",
|
||||
Fingerprint(request, SmmJsonContext.Default.EnrollmentRequest),
|
||||
SmmJsonContext.Default.EnrollmentResponse,
|
||||
cancellationToken);
|
||||
if (cached is not null)
|
||||
{
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return cached;
|
||||
}
|
||||
|
||||
var consume = connection.CreateCommand();
|
||||
consume.Transaction = transaction;
|
||||
consume.CommandText = """
|
||||
UPDATE enrollment_tokens
|
||||
SET consumed_at = $now
|
||||
WHERE token_hash = $hash
|
||||
AND node_id = $node
|
||||
AND consumed_at IS NULL
|
||||
AND expires_at >= $now;
|
||||
""";
|
||||
var now = DateTimeOffset.UtcNow.ToString("O");
|
||||
consume.Parameters.AddWithValue("$now", now);
|
||||
consume.Parameters.AddWithValue("$hash", Hash(request.Token));
|
||||
consume.Parameters.AddWithValue("$node", request.NodeId);
|
||||
if (await consume.ExecuteNonQueryAsync(cancellationToken) != 1)
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
return null;
|
||||
}
|
||||
|
||||
var issued = issueCertificate();
|
||||
var response = new EnrollmentResponse(
|
||||
request.NodeId,
|
||||
issued.CertificatePem,
|
||||
issued.CertificateAuthorityPem,
|
||||
issued.ExpiresAt);
|
||||
var upsert = connection.CreateCommand();
|
||||
upsert.Transaction = transaction;
|
||||
upsert.CommandText = """
|
||||
INSERT INTO agents(node_id, name, certificate_thumbprint, certificate_expires_at, status)
|
||||
VALUES ($node, $node, $thumbprint, $expires, 'Enrolled')
|
||||
ON CONFLICT(node_id) DO UPDATE SET
|
||||
certificate_thumbprint = excluded.certificate_thumbprint,
|
||||
certificate_expires_at = excluded.certificate_expires_at,
|
||||
status = 'Enrolled';
|
||||
""";
|
||||
upsert.Parameters.AddWithValue("$node", request.NodeId);
|
||||
upsert.Parameters.AddWithValue("$thumbprint", issued.Thumbprint);
|
||||
upsert.Parameters.AddWithValue("$expires", issued.ExpiresAt.ToString("O"));
|
||||
await upsert.ExecuteNonQueryAsync(cancellationToken);
|
||||
await WriteIdempotentAsync(
|
||||
connection,
|
||||
transaction,
|
||||
$"enroll:{request.IdempotencyKey}",
|
||||
Fingerprint(request, SmmJsonContext.Default.EnrollmentRequest),
|
||||
response,
|
||||
SmmJsonContext.Default.EnrollmentResponse,
|
||||
cancellationToken);
|
||||
await WriteAuditAsync(connection, transaction, request.NodeId, "agent.enroll", request.NodeId, "{}", cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return response;
|
||||
}
|
||||
|
||||
public async Task<bool> IsCertificateActiveAsync(
|
||||
string thumbprint,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await ResolveIdentityAsync(thumbprint, cancellationToken) is not null;
|
||||
}
|
||||
|
||||
public async Task<ControlIdentity?> ResolveIdentityAsync(
|
||||
string thumbprint,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = await OpenAsync(cancellationToken);
|
||||
var command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
SELECT node_id, 'Agent' FROM agents
|
||||
WHERE certificate_thumbprint = $thumbprint
|
||||
AND certificate_expires_at > $now
|
||||
AND status != 'Revoked'
|
||||
UNION ALL
|
||||
SELECT device_id, 'Operator' FROM devices
|
||||
WHERE certificate_thumbprint = $thumbprint
|
||||
AND certificate_expires_at > $now
|
||||
AND status != 'Revoked'
|
||||
LIMIT 1;
|
||||
""";
|
||||
command.Parameters.AddWithValue("$thumbprint", thumbprint);
|
||||
command.Parameters.AddWithValue("$now", DateTimeOffset.UtcNow.ToString("O"));
|
||||
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(
|
||||
string thumbprint,
|
||||
string nodeId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = await OpenAsync(cancellationToken);
|
||||
var command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM agents
|
||||
WHERE certificate_thumbprint = $thumbprint
|
||||
AND node_id = $node
|
||||
AND certificate_expires_at > $now
|
||||
AND status != 'Revoked');
|
||||
""";
|
||||
command.Parameters.AddWithValue("$thumbprint", thumbprint);
|
||||
command.Parameters.AddWithValue("$node", nodeId);
|
||||
command.Parameters.AddWithValue("$now", DateTimeOffset.UtcNow.ToString("O"));
|
||||
return Convert.ToInt32(await command.ExecuteScalarAsync(cancellationToken)) == 1;
|
||||
}
|
||||
|
||||
public async Task<AgentHeartbeatResponse> RecordHeartbeatAsync(
|
||||
AgentHeartbeat heartbeat,
|
||||
int nextHeartbeatSeconds,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = await OpenAsync(cancellationToken);
|
||||
await using var transaction = (SqliteTransaction)await connection.BeginTransactionAsync(cancellationToken);
|
||||
var cached = await ReadIdempotentAsync<AgentHeartbeatResponse>(
|
||||
connection,
|
||||
transaction,
|
||||
$"heartbeat:{heartbeat.NodeId}:{heartbeat.IdempotencyKey}",
|
||||
Fingerprint(heartbeat, SmmJsonContext.Default.AgentHeartbeat),
|
||||
SmmJsonContext.Default.AgentHeartbeatResponse,
|
||||
cancellationToken);
|
||||
if (cached is not null)
|
||||
{
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return cached;
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var update = connection.CreateCommand();
|
||||
update.Transaction = transaction;
|
||||
update.CommandText = """
|
||||
UPDATE agents
|
||||
SET status = 'Online', agent_version = $version, last_seen_at = $now
|
||||
WHERE node_id = $node;
|
||||
""";
|
||||
update.Parameters.AddWithValue("$version", heartbeat.AgentVersion);
|
||||
update.Parameters.AddWithValue("$now", now.ToString("O"));
|
||||
update.Parameters.AddWithValue("$node", heartbeat.NodeId);
|
||||
if (await update.ExecuteNonQueryAsync(cancellationToken) != 1)
|
||||
{
|
||||
throw new InvalidOperationException("Unknown agent node id.");
|
||||
}
|
||||
|
||||
var insert = connection.CreateCommand();
|
||||
insert.Transaction = transaction;
|
||||
insert.CommandText = """
|
||||
INSERT INTO metric_samples(node_id, recorded_at, payload_json)
|
||||
VALUES ($node, $now, $payload);
|
||||
SELECT last_insert_rowid();
|
||||
""";
|
||||
insert.Parameters.AddWithValue("$node", heartbeat.NodeId);
|
||||
insert.Parameters.AddWithValue("$now", now.ToString("O"));
|
||||
insert.Parameters.AddWithValue(
|
||||
"$payload",
|
||||
JsonSerializer.Serialize(heartbeat, SmmJsonContext.Default.AgentHeartbeat));
|
||||
var sequence = Convert.ToInt64(await insert.ExecuteScalarAsync(cancellationToken));
|
||||
var response = new AgentHeartbeatResponse(now, sequence, nextHeartbeatSeconds);
|
||||
await WriteIdempotentAsync(
|
||||
connection,
|
||||
transaction,
|
||||
$"heartbeat:{heartbeat.NodeId}:{heartbeat.IdempotencyKey}",
|
||||
Fingerprint(heartbeat, SmmJsonContext.Default.AgentHeartbeat),
|
||||
response,
|
||||
SmmJsonContext.Default.AgentHeartbeatResponse,
|
||||
cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return response;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AgentSummary>> ListAgentsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = new List<AgentSummary>();
|
||||
await using var connection = await OpenAsync(cancellationToken);
|
||||
var command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
SELECT node_id, name, status, agent_version, last_seen_at
|
||||
FROM agents ORDER BY name;
|
||||
""";
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
while (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
result.Add(new AgentSummary(
|
||||
reader.GetString(0),
|
||||
reader.GetString(1),
|
||||
reader.GetString(2),
|
||||
reader.GetString(3),
|
||||
reader.IsDBNull(4) ? null : DateTimeOffset.Parse(reader.GetString(4))));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<LinkMutation> CreateLinkMutationAsync(
|
||||
LinkPolicyCreateRequest request,
|
||||
string actor,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = await OpenAsync(cancellationToken);
|
||||
await using var transaction = (SqliteTransaction)await connection.BeginTransactionAsync(cancellationToken);
|
||||
var cached = await ReadIdempotentAsync<LinkPolicy>(
|
||||
connection,
|
||||
transaction,
|
||||
$"link-create:{actor}:{request.IdempotencyKey}",
|
||||
Fingerprint(request, SmmJsonContext.Default.LinkPolicyCreateRequest),
|
||||
SmmJsonContext.Default.LinkPolicy,
|
||||
cancellationToken);
|
||||
if (cached is not null)
|
||||
{
|
||||
var current = await ReadLinkAsync(connection, transaction, cached.Id, cancellationToken) ?? cached;
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return new LinkMutation(current, true);
|
||||
}
|
||||
|
||||
var nodes = connection.CreateCommand();
|
||||
nodes.Transaction = transaction;
|
||||
nodes.CommandText = """
|
||||
SELECT COUNT(*) FROM agents
|
||||
WHERE node_id IN ($source, $target) AND status != 'Revoked';
|
||||
""";
|
||||
nodes.Parameters.AddWithValue("$source", request.SourceNodeId);
|
||||
nodes.Parameters.AddWithValue("$target", request.TargetNodeId);
|
||||
if (Convert.ToInt32(await nodes.ExecuteScalarAsync(cancellationToken)) != 2)
|
||||
{
|
||||
throw new InvalidOperationException("Source or target agent is not registered.");
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
DateTimeOffset? expiresAt = request.TtlMinutes == 0
|
||||
? null
|
||||
: now.AddMinutes(request.TtlMinutes);
|
||||
var versionCommand = connection.CreateCommand();
|
||||
versionCommand.Transaction = transaction;
|
||||
versionCommand.CommandText = "SELECT COALESCE(MAX(version), 0) + 1 FROM links;";
|
||||
var version = Convert.ToInt64(await versionCommand.ExecuteScalarAsync(cancellationToken));
|
||||
var link = new LinkPolicy(
|
||||
Guid.NewGuid().ToString("N"),
|
||||
request.SourceNodeId,
|
||||
request.TargetNodeId,
|
||||
request.Protocol,
|
||||
request.Port,
|
||||
request.TtlMinutes,
|
||||
request.Reason,
|
||||
"Active",
|
||||
"Connecting",
|
||||
version,
|
||||
now,
|
||||
expiresAt,
|
||||
now,
|
||||
null);
|
||||
var insert = connection.CreateCommand();
|
||||
insert.Transaction = transaction;
|
||||
insert.CommandText = """
|
||||
INSERT INTO links(
|
||||
id, source_node_id, target_node_id, protocol, port, ttl_minutes, reason,
|
||||
desired_state, actual_state, version, created_at, expires_at, updated_at, last_error)
|
||||
VALUES (
|
||||
$id, $source, $target, $protocol, $port, $ttl, $reason,
|
||||
$desired, $actual, $version, $created, $expires, $updated, NULL);
|
||||
""";
|
||||
AddLinkParameters(insert, link);
|
||||
await insert.ExecuteNonQueryAsync(cancellationToken);
|
||||
await WriteIdempotentAsync(
|
||||
connection,
|
||||
transaction,
|
||||
$"link-create:{actor}:{request.IdempotencyKey}",
|
||||
Fingerprint(request, SmmJsonContext.Default.LinkPolicyCreateRequest),
|
||||
link,
|
||||
SmmJsonContext.Default.LinkPolicy,
|
||||
cancellationToken);
|
||||
await WriteAuditAsync(
|
||||
connection,
|
||||
transaction,
|
||||
actor,
|
||||
"link.connect.requested",
|
||||
link.Id,
|
||||
JsonSerializer.Serialize(link, SmmJsonContext.Default.LinkPolicy),
|
||||
cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return new LinkMutation(link, false);
|
||||
}
|
||||
|
||||
public async Task<LinkMutation?> BeginDisableLinkMutationAsync(
|
||||
string id,
|
||||
LinkPolicyDisableRequest request,
|
||||
string actor,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = await OpenAsync(cancellationToken);
|
||||
await using var transaction = (SqliteTransaction)await connection.BeginTransactionAsync(cancellationToken);
|
||||
var cached = await ReadIdempotentAsync<LinkPolicy>(
|
||||
connection,
|
||||
transaction,
|
||||
$"link-disable:{actor}:{id}:{request.IdempotencyKey}",
|
||||
Fingerprint(request, SmmJsonContext.Default.LinkPolicyDisableRequest),
|
||||
SmmJsonContext.Default.LinkPolicy,
|
||||
cancellationToken);
|
||||
if (cached is not null)
|
||||
{
|
||||
var current = await ReadLinkAsync(connection, transaction, cached.Id, cancellationToken) ?? cached;
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return new LinkMutation(current, true);
|
||||
}
|
||||
|
||||
var existing = await ReadLinkAsync(connection, transaction, id, cancellationToken);
|
||||
if (existing is null)
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
return null;
|
||||
}
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var link = existing with
|
||||
{
|
||||
DesiredState = "Disabled",
|
||||
ActualState = "Disconnecting",
|
||||
Version = existing.Version + 1,
|
||||
UpdatedAt = now,
|
||||
LastError = null
|
||||
};
|
||||
var update = connection.CreateCommand();
|
||||
update.Transaction = transaction;
|
||||
update.CommandText = """
|
||||
UPDATE links SET
|
||||
desired_state = $desired,
|
||||
actual_state = $actual,
|
||||
version = $version,
|
||||
updated_at = $updated,
|
||||
last_error = NULL
|
||||
WHERE id = $id;
|
||||
""";
|
||||
AddLinkParameters(update, link);
|
||||
await update.ExecuteNonQueryAsync(cancellationToken);
|
||||
await WriteIdempotentAsync(
|
||||
connection,
|
||||
transaction,
|
||||
$"link-disable:{actor}:{id}:{request.IdempotencyKey}",
|
||||
Fingerprint(request, SmmJsonContext.Default.LinkPolicyDisableRequest),
|
||||
link,
|
||||
SmmJsonContext.Default.LinkPolicy,
|
||||
cancellationToken);
|
||||
await WriteAuditAsync(
|
||||
connection, transaction, actor, "link.disconnect.requested", id, "{}", cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return new LinkMutation(link, false);
|
||||
}
|
||||
|
||||
public async Task<LinkPolicy?> SetLinkActualStateAsync(
|
||||
string id,
|
||||
string state,
|
||||
string? error,
|
||||
string actor,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = await OpenAsync(cancellationToken);
|
||||
await using var transaction = (SqliteTransaction)await connection.BeginTransactionAsync(cancellationToken);
|
||||
var update = connection.CreateCommand();
|
||||
update.Transaction = transaction;
|
||||
update.CommandText = """
|
||||
UPDATE links SET actual_state = $state, last_error = $error, updated_at = $now
|
||||
WHERE id = $id;
|
||||
""";
|
||||
update.Parameters.AddWithValue("$state", state);
|
||||
update.Parameters.AddWithValue("$error", (object?)error ?? DBNull.Value);
|
||||
update.Parameters.AddWithValue("$now", DateTimeOffset.UtcNow.ToString("O"));
|
||||
update.Parameters.AddWithValue("$id", id);
|
||||
if (await update.ExecuteNonQueryAsync(cancellationToken) != 1)
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
return null;
|
||||
}
|
||||
var link = await ReadLinkAsync(connection, transaction, id, cancellationToken);
|
||||
await WriteAuditAsync(
|
||||
connection,
|
||||
transaction,
|
||||
actor,
|
||||
$"link.state.{state.ToLowerInvariant()}",
|
||||
id,
|
||||
error is null
|
||||
? "{}"
|
||||
: JsonSerializer.Serialize(new ControlError(error), SmmJsonContext.Default.ControlError),
|
||||
cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return link;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<LinkPolicy>> ListLinksAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = new List<LinkPolicy>();
|
||||
await using var connection = await OpenAsync(cancellationToken);
|
||||
var command = connection.CreateCommand();
|
||||
command.CommandText = "SELECT * FROM links ORDER BY created_at DESC;";
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
while (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
result.Add(ReadLink(reader));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void AddLinkParameters(SqliteCommand command, LinkPolicy link)
|
||||
{
|
||||
command.Parameters.AddWithValue("$id", link.Id);
|
||||
command.Parameters.AddWithValue("$source", link.SourceNodeId);
|
||||
command.Parameters.AddWithValue("$target", link.TargetNodeId);
|
||||
command.Parameters.AddWithValue("$protocol", link.Protocol);
|
||||
command.Parameters.AddWithValue("$port", link.Port);
|
||||
command.Parameters.AddWithValue("$ttl", link.TtlMinutes);
|
||||
command.Parameters.AddWithValue("$reason", link.Reason);
|
||||
command.Parameters.AddWithValue("$desired", link.DesiredState);
|
||||
command.Parameters.AddWithValue("$actual", link.ActualState);
|
||||
command.Parameters.AddWithValue("$version", link.Version);
|
||||
command.Parameters.AddWithValue("$created", link.CreatedAt.ToString("O"));
|
||||
command.Parameters.AddWithValue(
|
||||
"$expires",
|
||||
link.ExpiresAt is null ? DBNull.Value : link.ExpiresAt.Value.ToString("O"));
|
||||
command.Parameters.AddWithValue("$updated", link.UpdatedAt.ToString("O"));
|
||||
}
|
||||
|
||||
private static async Task<LinkPolicy?> ReadLinkAsync(
|
||||
SqliteConnection connection,
|
||||
SqliteTransaction transaction,
|
||||
string id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var command = connection.CreateCommand();
|
||||
command.Transaction = transaction;
|
||||
command.CommandText = "SELECT * FROM links WHERE id = $id;";
|
||||
command.Parameters.AddWithValue("$id", id);
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
return await reader.ReadAsync(cancellationToken) ? ReadLink(reader) : null;
|
||||
}
|
||||
|
||||
private static LinkPolicy ReadLink(SqliteDataReader reader)
|
||||
=> new(
|
||||
reader.GetString(0),
|
||||
reader.GetString(1),
|
||||
reader.GetString(2),
|
||||
reader.GetString(3),
|
||||
reader.GetInt32(4),
|
||||
reader.GetInt32(5),
|
||||
reader.GetString(6),
|
||||
reader.GetString(7),
|
||||
reader.GetString(8),
|
||||
reader.GetInt64(9),
|
||||
DateTimeOffset.Parse(reader.GetString(10)),
|
||||
reader.IsDBNull(11) ? null : DateTimeOffset.Parse(reader.GetString(11)),
|
||||
DateTimeOffset.Parse(reader.GetString(12)),
|
||||
reader.IsDBNull(13) ? null : reader.GetString(13));
|
||||
|
||||
private async Task<SqliteConnection> OpenAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var connection = new SqliteConnection(_connectionString);
|
||||
await connection.OpenAsync(cancellationToken);
|
||||
return connection;
|
||||
}
|
||||
|
||||
private static string Hash(string value)
|
||||
=> Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value)));
|
||||
|
||||
private static string Fingerprint<T>(T value, JsonTypeInfo<T> typeInfo)
|
||||
=> Hash(JsonSerializer.Serialize(value, typeInfo));
|
||||
|
||||
private static async Task<T?> ReadIdempotentAsync<T>(
|
||||
SqliteConnection connection,
|
||||
SqliteTransaction transaction,
|
||||
string key,
|
||||
string requestHash,
|
||||
JsonTypeInfo<T> typeInfo,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var command = connection.CreateCommand();
|
||||
command.Transaction = transaction;
|
||||
command.CommandText = "SELECT request_hash, response_json FROM idempotency WHERE operation_key = $key;";
|
||||
command.Parameters.AddWithValue("$key", key);
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
if (!await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
return default;
|
||||
}
|
||||
if (!string.Equals(reader.GetString(0), requestHash, StringComparison.Ordinal))
|
||||
{
|
||||
throw new IdempotencyConflictException();
|
||||
}
|
||||
return JsonSerializer.Deserialize(reader.GetString(1), typeInfo);
|
||||
}
|
||||
|
||||
private static async Task WriteIdempotentAsync<T>(
|
||||
SqliteConnection connection,
|
||||
SqliteTransaction transaction,
|
||||
string key,
|
||||
string requestHash,
|
||||
T response,
|
||||
JsonTypeInfo<T> typeInfo,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var command = connection.CreateCommand();
|
||||
command.Transaction = transaction;
|
||||
command.CommandText = """
|
||||
INSERT INTO idempotency(operation_key, request_hash, response_json, created_at)
|
||||
VALUES ($key, $requestHash, $response, $now);
|
||||
""";
|
||||
command.Parameters.AddWithValue("$key", key);
|
||||
command.Parameters.AddWithValue("$requestHash", requestHash);
|
||||
command.Parameters.AddWithValue("$response", JsonSerializer.Serialize(response, typeInfo));
|
||||
command.Parameters.AddWithValue("$now", DateTimeOffset.UtcNow.ToString("O"));
|
||||
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task WriteAuditAsync(
|
||||
SqliteConnection connection,
|
||||
SqliteTransaction transaction,
|
||||
string actor,
|
||||
string action,
|
||||
string subject,
|
||||
string details,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var command = connection.CreateCommand();
|
||||
command.Transaction = transaction;
|
||||
command.CommandText = """
|
||||
INSERT INTO audit(recorded_at, actor, action, subject, details_json)
|
||||
VALUES ($now, $actor, $action, $subject, $details);
|
||||
""";
|
||||
command.Parameters.AddWithValue("$now", DateTimeOffset.UtcNow.ToString("O"));
|
||||
command.Parameters.AddWithValue("$actor", actor);
|
||||
command.Parameters.AddWithValue("$action", action);
|
||||
command.Parameters.AddWithValue("$subject", subject);
|
||||
command.Parameters.AddWithValue("$details", details);
|
||||
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class IdempotencyConflictException : Exception
|
||||
{
|
||||
public IdempotencyConflictException()
|
||||
: base("Idempotency key was reused with a different request.")
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record ControlIdentity(string Id, string Role);
|
||||
|
||||
public sealed record LinkMutation(LinkPolicy Link, bool IsReplay);
|
||||
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
|
|
@ -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.";
|
||||
}
|
||||
382
src/ServerMonitorManager.Control/Program.cs
Normal file
|
|
@ -0,0 +1,382 @@
|
|||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text.Json;
|
||||
using System.Threading.RateLimiting;
|
||||
using Microsoft.AspNetCore.Authentication.Certificate;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Server.Kestrel.Https;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ServerMonitorManager.Control;
|
||||
using ServerMonitorManager.Core;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddProblemDetails();
|
||||
builder.Services.AddHealthChecks();
|
||||
builder.Services.AddRateLimiter(options =>
|
||||
{
|
||||
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||
options.AddPolicy("enrollment", context => RateLimitPartition.GetFixedWindowLimiter(
|
||||
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
|
||||
_ => new FixedWindowRateLimiterOptions
|
||||
{
|
||||
PermitLimit = 10,
|
||||
Window = TimeSpan.FromMinutes(1),
|
||||
QueueLimit = 0,
|
||||
AutoReplenishment = true
|
||||
}));
|
||||
});
|
||||
builder.Services.ConfigureHttpJsonOptions(options =>
|
||||
options.SerializerOptions.TypeInfoResolverChain.Insert(0, SmmJsonContext.Default));
|
||||
builder.Services.AddOptions<ControlOptions>()
|
||||
.Bind(builder.Configuration.GetSection(ControlOptions.SectionName))
|
||||
.Validate(options =>
|
||||
!string.IsNullOrWhiteSpace(options.DatabasePath)
|
||||
&& !string.IsNullOrWhiteSpace(options.CertificateAuthorityPath)
|
||||
&& options.HeartbeatSeconds is >= 10 and <= 300,
|
||||
"Control paths are required and HeartbeatSeconds must be between 10 and 300.")
|
||||
.ValidateOnStart();
|
||||
builder.Services.AddSingleton<ControlStore>();
|
||||
builder.Services.AddSingleton<CertificateAuthority>();
|
||||
builder.Services.AddSingleton<ControlEventBroker>();
|
||||
builder.Services.AddSingleton<ILinkPolicyApplier, LinkPolicyApplier>();
|
||||
builder.Services.AddSingleton<LinkService>();
|
||||
builder.Services.AddAuthentication(CertificateAuthenticationDefaults.AuthenticationScheme)
|
||||
.AddCertificate(options =>
|
||||
{
|
||||
options.AllowedCertificateTypes = CertificateTypes.All;
|
||||
options.RevocationMode = X509RevocationMode.NoCheck;
|
||||
options.ValidateCertificateUse = true;
|
||||
options.ValidateValidityPeriod = true;
|
||||
options.Events = new CertificateAuthenticationEvents
|
||||
{
|
||||
OnCertificateValidated = async context =>
|
||||
{
|
||||
var store = context.HttpContext.RequestServices.GetRequiredService<ControlStore>();
|
||||
var identity = await store.ResolveIdentityAsync(context.ClientCertificate.Thumbprint);
|
||||
if (identity is null)
|
||||
{
|
||||
context.Fail("The agent certificate is unknown, expired, or revoked.");
|
||||
return;
|
||||
}
|
||||
|
||||
context.Principal = new ClaimsPrincipal(new ClaimsIdentity(
|
||||
[
|
||||
new Claim(ClaimTypes.NameIdentifier, identity.Id),
|
||||
new Claim(ClaimTypes.Role, identity.Role)
|
||||
],
|
||||
context.Scheme.Name));
|
||||
context.Success();
|
||||
}
|
||||
};
|
||||
});
|
||||
builder.Services.AddOptions<CertificateAuthenticationOptions>(
|
||||
CertificateAuthenticationDefaults.AuthenticationScheme)
|
||||
.Configure<CertificateAuthority>((options, authority) =>
|
||||
{
|
||||
options.ChainTrustValidationMode = X509ChainTrustMode.CustomRootTrust;
|
||||
options.CustomTrustStore.Add(authority.PublicCertificate);
|
||||
});
|
||||
builder.Services.AddAuthorization(options =>
|
||||
{
|
||||
options.AddPolicy("Agent", policy => policy.RequireRole("Agent"));
|
||||
options.AddPolicy("Operator", policy => policy.RequireRole("Operator"));
|
||||
});
|
||||
builder.WebHost.ConfigureKestrel(options =>
|
||||
{
|
||||
options.Limits.MaxRequestBodySize = 64 * 1024;
|
||||
options.ConfigureHttpsDefaults(https =>
|
||||
https.ClientCertificateMode = ClientCertificateMode.AllowCertificate);
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
app.UseExceptionHandler();
|
||||
app.UseHsts();
|
||||
app.UseRateLimiter();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
var store = app.Services.GetRequiredService<ControlStore>();
|
||||
await store.InitializeAsync();
|
||||
|
||||
if (args is ["token-create", var nodeId])
|
||||
{
|
||||
if (!NodeIdValidator.IsValid(nodeId))
|
||||
{
|
||||
Console.Error.WriteLine("Node id must contain 1-63 lowercase letters, digits, or hyphens.");
|
||||
return 2;
|
||||
}
|
||||
|
||||
Console.WriteLine(await store.CreateEnrollmentTokenAsync(nodeId, TimeSpan.FromMinutes(10)));
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (args is ["device-token-create", var deviceId])
|
||||
{
|
||||
if (!NodeIdValidator.IsValid(deviceId))
|
||||
{
|
||||
Console.Error.WriteLine("Device id must contain 1-63 lowercase letters, digits, or hyphens.");
|
||||
return 2;
|
||||
}
|
||||
|
||||
Console.WriteLine(await store.CreateDeviceEnrollmentTokenAsync(deviceId, TimeSpan.FromMinutes(10)));
|
||||
return 0;
|
||||
}
|
||||
|
||||
app.MapHealthChecks("/healthz").AllowAnonymous();
|
||||
|
||||
app.MapPost("/api/v1/enroll", async (
|
||||
EnrollmentRequest request,
|
||||
ControlStore controlStore,
|
||||
CertificateAuthority authority,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!NodeIdValidator.IsValid(request.NodeId)
|
||||
|| string.IsNullOrWhiteSpace(request.Token)
|
||||
|| string.IsNullOrWhiteSpace(request.CertificateSigningRequestPem)
|
||||
|| !IdempotencyKeyValidator.IsValid(request.IdempotencyKey))
|
||||
{
|
||||
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
["request"] = ["Invalid enrollment request."]
|
||||
});
|
||||
}
|
||||
|
||||
EnrollmentResponse? response;
|
||||
try
|
||||
{
|
||||
response = await controlStore.EnrollAsync(
|
||||
request,
|
||||
() => authority.IssueClientCertificate(request.NodeId, request.CertificateSigningRequestPem),
|
||||
cancellationToken);
|
||||
}
|
||||
catch (IdempotencyConflictException)
|
||||
{
|
||||
return Results.Conflict(new ProblemDetails
|
||||
{
|
||||
Title = "Idempotency key conflict",
|
||||
Status = StatusCodes.Status409Conflict
|
||||
});
|
||||
}
|
||||
catch (Exception exception) when (exception is CryptographicException or InvalidOperationException)
|
||||
{
|
||||
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
["certificateSigningRequestPem"] = ["Invalid certificate signing request."]
|
||||
});
|
||||
}
|
||||
|
||||
return response is null
|
||||
? Results.Unauthorized()
|
||||
: Results.Ok(response);
|
||||
}).AllowAnonymous().RequireRateLimiting("enrollment");
|
||||
|
||||
app.MapPost("/api/v1/device-enroll", async (
|
||||
DeviceEnrollmentRequest request,
|
||||
ControlStore controlStore,
|
||||
CertificateAuthority authority,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!NodeIdValidator.IsValid(request.DeviceId)
|
||||
|| string.IsNullOrWhiteSpace(request.Token)
|
||||
|| string.IsNullOrWhiteSpace(request.CertificateSigningRequestPem)
|
||||
|| !IdempotencyKeyValidator.IsValid(request.IdempotencyKey))
|
||||
{
|
||||
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
["request"] = ["Invalid device enrollment request."]
|
||||
});
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var response = await controlStore.EnrollDeviceAsync(
|
||||
request,
|
||||
() => authority.IssueClientCertificate(request.DeviceId, request.CertificateSigningRequestPem),
|
||||
cancellationToken);
|
||||
return response is null ? Results.Unauthorized() : Results.Ok(response);
|
||||
}
|
||||
catch (IdempotencyConflictException)
|
||||
{
|
||||
return Results.Conflict(new ProblemDetails
|
||||
{
|
||||
Title = "Idempotency key conflict",
|
||||
Status = StatusCodes.Status409Conflict
|
||||
});
|
||||
}
|
||||
catch (Exception exception) when (exception is CryptographicException or InvalidOperationException)
|
||||
{
|
||||
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
["certificateSigningRequestPem"] = ["Invalid certificate signing request."]
|
||||
});
|
||||
}
|
||||
}).AllowAnonymous().RequireRateLimiting("enrollment");
|
||||
|
||||
var agents = app.MapGroup("/api/v1/agents").RequireAuthorization("Agent");
|
||||
agents.MapPost("/heartbeat", async (
|
||||
AgentHeartbeat heartbeat,
|
||||
HttpContext context,
|
||||
ControlStore controlStore,
|
||||
IOptions<ControlOptions> options,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!NodeIdValidator.IsValid(heartbeat.NodeId)
|
||||
|| !IdempotencyKeyValidator.IsValid(heartbeat.IdempotencyKey)
|
||||
|| heartbeat.SentAt < DateTimeOffset.UtcNow.AddMinutes(-5)
|
||||
|| heartbeat.SentAt > DateTimeOffset.UtcNow.AddMinutes(1))
|
||||
{
|
||||
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
["heartbeat"] = ["Invalid or stale heartbeat."]
|
||||
});
|
||||
}
|
||||
|
||||
var certificate = await context.Connection.GetClientCertificateAsync(cancellationToken);
|
||||
if (certificate is null)
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
if (!await controlStore.IsCertificateForNodeAsync(
|
||||
certificate.Thumbprint,
|
||||
heartbeat.NodeId,
|
||||
cancellationToken))
|
||||
{
|
||||
return Results.Forbid();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var response = await controlStore.RecordHeartbeatAsync(
|
||||
heartbeat,
|
||||
options.Value.HeartbeatSeconds,
|
||||
cancellationToken);
|
||||
var broker = context.RequestServices.GetRequiredService<ControlEventBroker>();
|
||||
broker.Publish(
|
||||
"agent.heartbeat",
|
||||
heartbeat.NodeId,
|
||||
JsonSerializer.Serialize(heartbeat, SmmJsonContext.Default.AgentHeartbeat));
|
||||
return Results.Ok(response);
|
||||
}
|
||||
catch (IdempotencyConflictException)
|
||||
{
|
||||
return Results.Conflict(new ProblemDetails
|
||||
{
|
||||
Title = "Idempotency key conflict",
|
||||
Status = StatusCodes.Status409Conflict
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
var control = app.MapGroup("/api/v1/control").RequireAuthorization("Operator");
|
||||
control.MapGet("/agents", async (ControlStore controlStore, CancellationToken cancellationToken) =>
|
||||
Results.Ok((await controlStore.ListAgentsAsync(cancellationToken)).ToArray()));
|
||||
control.MapGet("/links", async (ControlStore controlStore, CancellationToken cancellationToken) =>
|
||||
Results.Ok((await controlStore.ListLinksAsync(cancellationToken)).ToArray()));
|
||||
control.MapPost("/links", async (
|
||||
LinkPolicyCreateRequest request,
|
||||
HttpContext context,
|
||||
LinkService linkService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!LinkPolicyValidator.IsValid(request))
|
||||
{
|
||||
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
["link"] = ["Invalid source, target, protocol, port, TTL, reason, or idempotency key."]
|
||||
});
|
||||
}
|
||||
var actor = context.User.FindFirstValue(ClaimTypes.NameIdentifier)!;
|
||||
try
|
||||
{
|
||||
var link = await linkService.CreateAsync(request, actor, cancellationToken);
|
||||
return Results.Created($"/api/v1/control/links/{link.Id}", link);
|
||||
}
|
||||
catch (IdempotencyConflictException)
|
||||
{
|
||||
return Results.Conflict(new ProblemDetails { Title = "Idempotency key conflict" });
|
||||
}
|
||||
catch (SqliteException exception) when (exception.SqliteErrorCode == 19)
|
||||
{
|
||||
return Results.Conflict(new ProblemDetails { Title = "An active Link already exists." });
|
||||
}
|
||||
catch (InvalidOperationException exception)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails { Title = exception.Message });
|
||||
}
|
||||
});
|
||||
control.MapPost("/links/{id}/disable", async (
|
||||
string id,
|
||||
LinkPolicyDisableRequest request,
|
||||
HttpContext context,
|
||||
LinkService linkService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (id.Length != 32 || !Guid.TryParseExact(id, "N", out _)
|
||||
|| !IdempotencyKeyValidator.IsValid(request.IdempotencyKey))
|
||||
{
|
||||
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
["link"] = ["Invalid Link id or idempotency key."]
|
||||
});
|
||||
}
|
||||
var actor = context.User.FindFirstValue(ClaimTypes.NameIdentifier)!;
|
||||
try
|
||||
{
|
||||
var link = await linkService.DisableAsync(id, request, actor, cancellationToken);
|
||||
return link is null ? Results.NotFound() : Results.Ok(link);
|
||||
}
|
||||
catch (IdempotencyConflictException)
|
||||
{
|
||||
return Results.Conflict(new ProblemDetails { Title = "Idempotency key conflict" });
|
||||
}
|
||||
});
|
||||
control.MapGet("/events", async (HttpContext context, ControlEventBroker broker) =>
|
||||
{
|
||||
context.Response.ContentType = "application/x-ndjson";
|
||||
context.Response.Headers.CacheControl = "no-store";
|
||||
using var subscription = broker.Subscribe();
|
||||
await foreach (var controlEvent in subscription.Reader.ReadAllAsync(context.RequestAborted))
|
||||
{
|
||||
await JsonSerializer.SerializeAsync(
|
||||
context.Response.Body,
|
||||
controlEvent,
|
||||
SmmJsonContext.Default.ControlEvent,
|
||||
context.RequestAborted);
|
||||
await context.Response.WriteAsync("\n", context.RequestAborted);
|
||||
await context.Response.Body.FlushAsync(context.RequestAborted);
|
||||
}
|
||||
});
|
||||
|
||||
await app.RunAsync();
|
||||
return 0;
|
||||
|
||||
public partial class Program;
|
||||
|
||||
internal static class NodeIdValidator
|
||||
{
|
||||
public static bool IsValid(string value)
|
||||
=> value.Length is >= 1 and <= 63
|
||||
&& value.All(character => character is >= 'a' and <= 'z' or >= '0' and <= '9' or '-');
|
||||
}
|
||||
|
||||
internal static class IdempotencyKeyValidator
|
||||
{
|
||||
public static bool IsValid(string value)
|
||||
=> Guid.TryParse(value, out _);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<Version>0.1.0</Version>
|
||||
<AssemblyName>ochenstarik-smm-control</AssemblyName>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ServerMonitorManager.Core\ServerMonitorManager.Core.csproj" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.Certificate" Version="10.0.10" />
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.10" />
|
||||
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="2.1.12" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Update="appsettings.json" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
16
src/ServerMonitorManager.Control/appsettings.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"Control": {
|
||||
"DatabasePath": "/var/lib/ochenstarik-server-monitor-manager/control.db",
|
||||
"CertificateAuthorityPath": "/etc/ochenstarik-server-monitor-manager/control-ca.pfx",
|
||||
"CertificateAuthorityPassword": null,
|
||||
"HeartbeatSeconds": 30,
|
||||
"HubHelperPath": "/usr/local/libexec/ochenstarik-smm-policy-apply"
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
87
src/ServerMonitorManager.Core/Contracts.cs
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
namespace ServerMonitorManager.Core;
|
||||
|
||||
public sealed record EnrollmentRequest(
|
||||
string NodeId,
|
||||
string Token,
|
||||
string CertificateSigningRequestPem,
|
||||
string IdempotencyKey);
|
||||
|
||||
public sealed record EnrollmentResponse(
|
||||
string NodeId,
|
||||
string CertificatePem,
|
||||
string CertificateAuthorityPem,
|
||||
DateTimeOffset ExpiresAt);
|
||||
|
||||
public sealed record AgentHeartbeat(
|
||||
string NodeId,
|
||||
string AgentVersion,
|
||||
DateTimeOffset SentAt,
|
||||
double LoadOne,
|
||||
long MemoryUsedBytes,
|
||||
long MemoryTotalBytes,
|
||||
long DiskUsedBytes,
|
||||
long DiskTotalBytes,
|
||||
long NetworkReceiveBytes,
|
||||
long NetworkTransmitBytes,
|
||||
long UptimeSeconds,
|
||||
string IdempotencyKey);
|
||||
|
||||
public sealed record AgentHeartbeatResponse(
|
||||
DateTimeOffset AcceptedAt,
|
||||
long Sequence,
|
||||
int NextHeartbeatSeconds);
|
||||
|
||||
public sealed record AgentSummary(
|
||||
string NodeId,
|
||||
string Name,
|
||||
string Status,
|
||||
string AgentVersion,
|
||||
DateTimeOffset? LastSeenAt);
|
||||
|
||||
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);
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
18
src/ServerMonitorManager.Core/SmmJsonContext.cs
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ServerMonitorManager.Core;
|
||||
|
||||
[JsonSerializable(typeof(EnrollmentRequest))]
|
||||
[JsonSerializable(typeof(EnrollmentResponse))]
|
||||
[JsonSerializable(typeof(AgentHeartbeat))]
|
||||
[JsonSerializable(typeof(AgentHeartbeatResponse))]
|
||||
[JsonSerializable(typeof(AgentSummary[]))]
|
||||
[JsonSerializable(typeof(DeviceEnrollmentRequest))]
|
||||
[JsonSerializable(typeof(DeviceEnrollmentResponse))]
|
||||
[JsonSerializable(typeof(LinkPolicyCreateRequest))]
|
||||
[JsonSerializable(typeof(LinkPolicyDisableRequest))]
|
||||
[JsonSerializable(typeof(LinkPolicy))]
|
||||
[JsonSerializable(typeof(LinkPolicy[]))]
|
||||
[JsonSerializable(typeof(ControlEvent))]
|
||||
[JsonSerializable(typeof(ControlError))]
|
||||
public sealed partial class SmmJsonContext : JsonSerializerContext;
|
||||
54
src/ServerMonitorManager.Desktop/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
## .NET / Visual Studio
|
||||
[Bb]in/
|
||||
[Oo]bj/
|
||||
[Dd]ebug/
|
||||
[Rr]elease/
|
||||
.vs/
|
||||
*.user
|
||||
*.suo
|
||||
*.userosscache
|
||||
*.sln.docstates
|
||||
artifacts/
|
||||
|
||||
# Build logs
|
||||
[Ll]og/
|
||||
[Ll]ogs/
|
||||
*.log
|
||||
*.binlog
|
||||
|
||||
# Test results
|
||||
[Tt]est[Rr]esult*/
|
||||
*.trx
|
||||
*.coverage
|
||||
*.coveragexml
|
||||
|
||||
# NuGet
|
||||
*.nupkg
|
||||
*.snupkg
|
||||
*.nuget.props
|
||||
*.nuget.targets
|
||||
project.lock.json
|
||||
|
||||
# MSIX packaging output
|
||||
AppPackages/
|
||||
BundleArtifacts/
|
||||
*.msix
|
||||
*.msixupload
|
||||
*.appx
|
||||
*.appxbundle
|
||||
*.appxupload
|
||||
|
||||
# Publish output
|
||||
publish/
|
||||
*.pubxml
|
||||
PublishScripts/
|
||||
|
||||
# Code analysis and tooling
|
||||
_ReSharper*/
|
||||
*.DotSettings.user
|
||||
*.dotCover
|
||||
.idea/
|
||||
|
||||
# Generated files
|
||||
Generated\ Files/
|
||||
*_wpftmp.csproj
|
||||
16
src/ServerMonitorManager.Desktop/App.xaml
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Application
|
||||
x:Class="ServerMonitorManager_Desktop.App"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="using:ServerMonitorManager_Desktop">
|
||||
<Application.Resources>
|
||||
<ResourceDictionary>
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
<XamlControlsResources xmlns="using:Microsoft.UI.Xaml.Controls" />
|
||||
<!-- Other merged dictionaries here -->
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
<!-- Other app resources here -->
|
||||
</ResourceDictionary>
|
||||
</Application.Resources>
|
||||
</Application>
|
||||
44
src/ServerMonitorManager.Desktop/App.xaml.cs
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
using Windows.ApplicationModel;
|
||||
using Windows.ApplicationModel.Activation;
|
||||
using Windows.Foundation;
|
||||
using Windows.Foundation.Collections;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
using Microsoft.UI.Xaml.Controls.Primitives;
|
||||
using Microsoft.UI.Xaml.Data;
|
||||
using Microsoft.UI.Xaml.Input;
|
||||
using Microsoft.UI.Xaml.Media;
|
||||
using Microsoft.UI.Xaml.Navigation;
|
||||
using Microsoft.UI.Xaml.Shapes;
|
||||
|
||||
// To learn more about WinUI, the WinUI project structure,
|
||||
// and more about our project templates, see: http://aka.ms/winui-project-info.
|
||||
|
||||
namespace ServerMonitorManager_Desktop;
|
||||
|
||||
/// <summary>
|
||||
/// Provides application-specific behavior to supplement the default Application class.
|
||||
/// </summary>
|
||||
public partial class App : Application
|
||||
{
|
||||
private Window? _window;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the singleton application object. This is the first line of authored code
|
||||
/// executed, and as such is the logical equivalent of main() or WinMain().
|
||||
/// </summary>
|
||||
public App()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invoked when the application is launched.
|
||||
/// </summary>
|
||||
/// <param name="args">Details about the launch request and process.</param>
|
||||
protected override void OnLaunched(Microsoft.UI.Xaml.LaunchActivatedEventArgs args)
|
||||
{
|
||||
_window = new MainWindow();
|
||||
_window.Activate();
|
||||
}
|
||||
}
|
||||
BIN
src/ServerMonitorManager.Desktop/Assets/AppIcon.ico
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
src/ServerMonitorManager.Desktop/Assets/AppIcon.png
Normal file
|
After Width: | Height: | Size: 846 KiB |
BIN
src/ServerMonitorManager.Desktop/Assets/AppIconSmall.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
src/ServerMonitorManager.Desktop/Assets/Favicon.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
BIN
src/ServerMonitorManager.Desktop/Assets/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
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();
|
||||
}
|
||||
}
|
||||
421
src/ServerMonitorManager.Desktop/MainPage.xaml
Normal file
|
|
@ -0,0 +1,421 @@
|
|||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<Page
|
||||
x:Class="ServerMonitorManager_Desktop.MainPage"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:local="using:ServerMonitorManager_Desktop"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d">
|
||||
|
||||
<NavigationView
|
||||
x:Name="MainNavigation"
|
||||
AlwaysShowHeader="False"
|
||||
IsBackButtonVisible="Collapsed"
|
||||
IsSettingsVisible="True"
|
||||
PaneDisplayMode="LeftCompact"
|
||||
SelectionChanged="MainNavigation_SelectionChanged">
|
||||
<NavigationView.MenuItems>
|
||||
<NavigationViewItem Content="Обзор" Tag="overview">
|
||||
<NavigationViewItem.Icon>
|
||||
<SymbolIcon Symbol="Home" />
|
||||
</NavigationViewItem.Icon>
|
||||
</NavigationViewItem>
|
||||
<NavigationViewItem Content="Серверы" Tag="servers">
|
||||
<NavigationViewItem.Icon>
|
||||
<SymbolIcon Symbol="Library" />
|
||||
</NavigationViewItem.Icon>
|
||||
</NavigationViewItem>
|
||||
<NavigationViewItem Content="Связи" Tag="links">
|
||||
<NavigationViewItem.Icon>
|
||||
<SymbolIcon Symbol="Link" />
|
||||
</NavigationViewItem.Icon>
|
||||
</NavigationViewItem>
|
||||
<NavigationViewItem Content="Сессии" Tag="sessions">
|
||||
<NavigationViewItem.Icon>
|
||||
<SymbolIcon Symbol="Contact" />
|
||||
</NavigationViewItem.Icon>
|
||||
</NavigationViewItem>
|
||||
</NavigationView.MenuItems>
|
||||
|
||||
<Grid Padding="32,24,32,32">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock
|
||||
Style="{StaticResource TitleTextBlockStyle}"
|
||||
Text="Server Monitor Manager" />
|
||||
<TextBlock
|
||||
x:Name="HeaderStatusText"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Text="SSH monitoring · серверы ещё не добавлены" />
|
||||
</StackPanel>
|
||||
<CommandBar
|
||||
Grid.Column="1"
|
||||
Background="Transparent"
|
||||
DefaultLabelPosition="Right">
|
||||
<AppBarButton
|
||||
AutomationProperties.Name="Обновить метрики всех серверов"
|
||||
Click="RefreshButton_Click"
|
||||
Icon="Refresh"
|
||||
Label="Обновить" />
|
||||
<AppBarButton
|
||||
AutomationProperties.Name="Показать публичный SSH-ключ"
|
||||
Click="SshKeyButton_Click"
|
||||
Icon="Permissions"
|
||||
Label="SSH-ключ" />
|
||||
<AppBarButton
|
||||
AutomationProperties.Name="Подключить Control Hub"
|
||||
Click="ControlHubButton_Click"
|
||||
Icon="Link"
|
||||
Label="Control Hub" />
|
||||
<AppBarButton
|
||||
AutomationProperties.Name="Открыть прямой SSH-терминал"
|
||||
Click="TerminalButton_Click"
|
||||
Icon="OpenFile"
|
||||
Label="Терминал" />
|
||||
<AppBarButton
|
||||
AutomationProperties.Name="Добавить сервер"
|
||||
Click="AddServerButton_Click"
|
||||
Icon="Add"
|
||||
Label="Добавить сервер" />
|
||||
<AppBarButton
|
||||
AutomationProperties.Name="Изменить выбранный сервер"
|
||||
Click="EditServerButton_Click"
|
||||
Icon="Edit"
|
||||
Label="Изменить" />
|
||||
<AppBarButton
|
||||
AutomationProperties.Name="Удалить выбранный сервер"
|
||||
Click="DeleteServerButton_Click"
|
||||
Icon="Delete"
|
||||
Label="Удалить" />
|
||||
</CommandBar>
|
||||
</Grid>
|
||||
|
||||
<InfoBar
|
||||
x:Name="LinkActionInfo"
|
||||
Grid.Row="1"
|
||||
Margin="0,16,0,0"
|
||||
IsClosable="True"
|
||||
IsOpen="False"
|
||||
Severity="Informational" />
|
||||
|
||||
<Border
|
||||
x:Name="OverviewSummary"
|
||||
Grid.Row="2"
|
||||
Margin="0,24,0,0"
|
||||
Padding="20,18"
|
||||
Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
|
||||
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="8">
|
||||
<Grid ColumnSpacing="18">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="1" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="1" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="1" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Foreground="{ThemeResource TextFillColorSecondaryBrush}" Text="Доступность" />
|
||||
<TextBlock x:Name="AvailabilityValueText" FontSize="24" FontWeight="SemiBold" Text="0" />
|
||||
<TextBlock x:Name="AvailabilityDetailText" Foreground="{ThemeResource TextFillColorSecondaryBrush}" Text="Добавьте серверы" />
|
||||
</StackPanel>
|
||||
<Rectangle Grid.Column="1" Fill="{ThemeResource DividerStrokeColorDefaultBrush}" />
|
||||
<StackPanel Grid.Column="2" Spacing="4">
|
||||
<TextBlock Foreground="{ThemeResource TextFillColorSecondaryBrush}" Text="Средняя нагрузка" />
|
||||
<TextBlock x:Name="AverageLoadValueText" FontSize="24" FontWeight="SemiBold" Text="—" />
|
||||
<TextBlock Foreground="{ThemeResource TextFillColorSecondaryBrush}" Text="CPU за 5 минут" />
|
||||
</StackPanel>
|
||||
<Rectangle Grid.Column="3" Fill="{ThemeResource DividerStrokeColorDefaultBrush}" />
|
||||
<StackPanel Grid.Column="4" Spacing="4">
|
||||
<TextBlock Foreground="{ThemeResource TextFillColorSecondaryBrush}" Text="Активные связи" />
|
||||
<TextBlock x:Name="ActiveLinksValueText" FontSize="24" FontWeight="SemiBold" Text="0" />
|
||||
<TextBlock Foreground="{ThemeResource TextFillColorSecondaryBrush}" Text="разрешённые направления" />
|
||||
</StackPanel>
|
||||
<Rectangle Grid.Column="5" Fill="{ThemeResource DividerStrokeColorDefaultBrush}" />
|
||||
<StackPanel Grid.Column="6" Spacing="4">
|
||||
<TextBlock Foreground="{ThemeResource TextFillColorSecondaryBrush}" Text="Предупреждения" />
|
||||
<TextBlock x:Name="WarningValueText" FontSize="24" FontWeight="SemiBold" Text="0" />
|
||||
<TextBlock x:Name="WarningDetailText" Foreground="{ThemeResource TextFillColorSecondaryBrush}" Text="Нет данных" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<ScrollViewer x:Name="WorkspaceScroll" Grid.Row="3" Margin="0,28,0,0">
|
||||
<Grid x:Name="WorkspaceGrid" ColumnSpacing="24" RowSpacing="24">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition x:Name="InspectorColumn" Width="0" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<StackPanel x:Name="ServerWorkspace" Spacing="12">
|
||||
<Grid>
|
||||
<TextBlock Style="{StaticResource SubtitleTextBlockStyle}" Text="Серверы" />
|
||||
<Button
|
||||
HorizontalAlignment="Right"
|
||||
Background="Transparent"
|
||||
Content="Все серверы" />
|
||||
</Grid>
|
||||
|
||||
<TextBlock
|
||||
x:Name="EmptyServersText"
|
||||
Margin="12,24"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Text="Добавьте первый сервер и установите на нём публичный SSH-ключ." />
|
||||
<ListView
|
||||
x:Name="ServerList"
|
||||
ItemsSource="{x:Bind Servers}"
|
||||
SelectionChanged="ServerList_SelectionChanged"
|
||||
SelectionMode="Single">
|
||||
<ListView.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Grid MinHeight="78" ColumnSpacing="16">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="14" />
|
||||
<ColumnDefinition Width="2*" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock
|
||||
VerticalAlignment="Center"
|
||||
FontSize="18"
|
||||
Foreground="{ThemeResource AccentTextFillColorPrimaryBrush}"
|
||||
Text="●" />
|
||||
<StackPanel Grid.Column="1" VerticalAlignment="Center">
|
||||
<TextBlock FontWeight="SemiBold" Text="{Binding Name}" />
|
||||
<TextBlock Foreground="{ThemeResource TextFillColorSecondaryBrush}" Text="{Binding Endpoint}" />
|
||||
<TextBlock Foreground="{ThemeResource TextFillColorSecondaryBrush}" Text="{Binding Status}" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="2" VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding CpuText}" />
|
||||
<ProgressBar Margin="0,6,0,0" Maximum="100" Value="{Binding CpuPercent}" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="3" VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding MemoryText}" />
|
||||
<TextBlock Foreground="{ThemeResource TextFillColorSecondaryBrush}" Text="{Binding DiskText}" />
|
||||
<TextBlock Foreground="{ThemeResource TextFillColorSecondaryBrush}" Text="{Binding HealthText}" />
|
||||
</StackPanel>
|
||||
<TextBlock
|
||||
Grid.Column="4"
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Text="{Binding LatencyText}" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListView.ItemTemplate>
|
||||
</ListView>
|
||||
|
||||
<Border
|
||||
Padding="16"
|
||||
Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
|
||||
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="8">
|
||||
<StackPanel Spacing="10">
|
||||
<Grid>
|
||||
<StackPanel Spacing="2">
|
||||
<TextBlock FontWeight="SemiBold" Text="История ресурсов" />
|
||||
<TextBlock
|
||||
x:Name="HistoryCaptionText"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Text="Выберите сервер"
|
||||
TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
<StackPanel HorizontalAlignment="Right" Orientation="Horizontal" Spacing="12">
|
||||
<TextBlock Foreground="{ThemeResource AccentTextFillColorPrimaryBrush}" Text="CPU" />
|
||||
<TextBlock Foreground="{ThemeResource SystemFillColorCautionBrush}" Text="RAM" />
|
||||
<TextBlock Foreground="{ThemeResource SystemFillColorCriticalBrush}" Text="Диск" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
<Grid
|
||||
x:Name="HistoryChart"
|
||||
Height="170"
|
||||
SizeChanged="HistoryChart_SizeChanged">
|
||||
<Polyline
|
||||
x:Name="CpuHistoryLine"
|
||||
Stroke="{ThemeResource AccentFillColorDefaultBrush}"
|
||||
StrokeLineJoin="Round"
|
||||
StrokeThickness="2" />
|
||||
<Polyline
|
||||
x:Name="MemoryHistoryLine"
|
||||
Stroke="{ThemeResource SystemFillColorCautionBrush}"
|
||||
StrokeLineJoin="Round"
|
||||
StrokeThickness="2" />
|
||||
<Polyline
|
||||
x:Name="DiskHistoryLine"
|
||||
Stroke="{ThemeResource SystemFillColorCriticalBrush}"
|
||||
StrokeLineJoin="Round"
|
||||
StrokeThickness="2" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<Border
|
||||
x:Name="LinkInspector"
|
||||
Grid.Row="1"
|
||||
Margin="0,0,0,0"
|
||||
Padding="20"
|
||||
Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
|
||||
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="8">
|
||||
<StackPanel Spacing="18">
|
||||
<Grid>
|
||||
<StackPanel Spacing="3">
|
||||
<TextBlock Style="{StaticResource SubtitleTextBlockStyle}" Text="Связи серверов" />
|
||||
<TextBlock
|
||||
x:Name="MeshStatusText"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Text="Добавьте главный Mesh Hub" />
|
||||
</StackPanel>
|
||||
<Button
|
||||
HorizontalAlignment="Right"
|
||||
AutomationProperties.Name="Обновить список серверов и связей"
|
||||
Click="RefreshMeshButton_Click">
|
||||
<FontIcon Glyph="" />
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<Rectangle Height="1" Fill="{ThemeResource DividerStrokeColorDefaultBrush}" />
|
||||
|
||||
<ComboBox
|
||||
x:Name="SourceNodeBox"
|
||||
HorizontalAlignment="Stretch"
|
||||
DisplayMemberPath="Label"
|
||||
Header="Источник доступа"
|
||||
ItemsSource="{x:Bind MeshNodes}" />
|
||||
<ComboBox
|
||||
x:Name="TargetNodeBox"
|
||||
HorizontalAlignment="Stretch"
|
||||
DisplayMemberPath="Label"
|
||||
Header="Целевой сервер"
|
||||
ItemsSource="{x:Bind MeshNodes}" />
|
||||
|
||||
<Grid ColumnSpacing="8">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<ComboBox
|
||||
x:Name="LinkProtocolBox"
|
||||
Header="Протокол"
|
||||
SelectedIndex="0">
|
||||
<ComboBoxItem Content="TCP" Tag="tcp" />
|
||||
<ComboBoxItem Content="UDP" Tag="udp" />
|
||||
</ComboBox>
|
||||
<NumberBox
|
||||
x:Name="LinkPortBox"
|
||||
Grid.Column="1"
|
||||
Header="Порт"
|
||||
Maximum="65535"
|
||||
Minimum="1"
|
||||
SpinButtonPlacementMode="Compact"
|
||||
Value="22" />
|
||||
</Grid>
|
||||
|
||||
<NumberBox
|
||||
x:Name="LinkTtlBox"
|
||||
Header="TTL, минут (0 — до ручного отключения)"
|
||||
Maximum="525600"
|
||||
Minimum="0"
|
||||
SpinButtonPlacementMode="Compact"
|
||||
Value="120" />
|
||||
|
||||
<Grid ColumnSpacing="8">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Button
|
||||
HorizontalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Center"
|
||||
Click="ConnectLinkButton_Click"
|
||||
Content="Разрешить"
|
||||
Style="{StaticResource AccentButtonStyle}" />
|
||||
<Button
|
||||
Grid.Column="1"
|
||||
HorizontalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Center"
|
||||
Click="DisconnectLinkButton_Click"
|
||||
Content="Отключить" />
|
||||
</Grid>
|
||||
|
||||
<TextBlock FontWeight="SemiBold" Text="Разрешённые направления" />
|
||||
<ListView
|
||||
x:Name="MeshLinksList"
|
||||
MaxHeight="220"
|
||||
ItemsSource="{x:Bind MeshLinks}"
|
||||
SelectionMode="Single">
|
||||
<ListView.ItemTemplate>
|
||||
<DataTemplate x:DataType="local:MeshLinkViewModel">
|
||||
<TextBlock Margin="4,8" Text="{x:Bind Label}" />
|
||||
</DataTemplate>
|
||||
</ListView.ItemTemplate>
|
||||
</ListView>
|
||||
|
||||
<InfoBar
|
||||
IsClosable="False"
|
||||
IsOpen="True"
|
||||
Message="Связь направленная: AI-агент → Home разрешает агенту обращаться к Home. Обратное направление включается отдельно."
|
||||
Severity="Informational" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<VisualStateManager.VisualStateGroups>
|
||||
<VisualStateGroup>
|
||||
<VisualState x:Name="NarrowState" />
|
||||
<VisualState x:Name="WideState">
|
||||
<VisualState.StateTriggers>
|
||||
<AdaptiveTrigger MinWindowWidth="1080" />
|
||||
</VisualState.StateTriggers>
|
||||
<VisualState.Setters>
|
||||
<Setter Target="InspectorColumn.Width" Value="360" />
|
||||
<Setter Target="LinkInspector.(Grid.Row)" Value="0" />
|
||||
<Setter Target="LinkInspector.(Grid.Column)" Value="1" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
</VisualStateManager.VisualStateGroups>
|
||||
</Grid>
|
||||
</ScrollViewer>
|
||||
|
||||
<StackPanel
|
||||
x:Name="NavigationPlaceholder"
|
||||
Grid.Row="3"
|
||||
Margin="0,36,0,0"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Top"
|
||||
MaxWidth="560"
|
||||
Spacing="12"
|
||||
Visibility="Collapsed">
|
||||
<TextBlock x:Name="PlaceholderTitle" HorizontalAlignment="Center" Style="{StaticResource TitleTextBlockStyle}" />
|
||||
<TextBlock
|
||||
x:Name="PlaceholderDescription"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
TextAlignment="Center"
|
||||
TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</NavigationView>
|
||||
</Page>
|
||||
987
src/ServerMonitorManager.Desktop/MainPage.xaml.cs
Normal file
|
|
@ -0,0 +1,987 @@
|
|||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Automation;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
using Microsoft.UI.Xaml.Media;
|
||||
using ServerMonitorManager.Core;
|
||||
using Windows.ApplicationModel.DataTransfer;
|
||||
using Windows.Foundation;
|
||||
|
||||
namespace ServerMonitorManager_Desktop;
|
||||
|
||||
public sealed partial class MainPage : Page
|
||||
{
|
||||
private readonly ServerStorage _storage = new();
|
||||
private readonly SshMonitorService _ssh = new();
|
||||
private readonly MetricsHistoryStorage _historyStorage = new();
|
||||
private readonly ControlClientService _control = new();
|
||||
private readonly List<MetricSampleData> _history = [];
|
||||
private readonly DispatcherTimer _refreshTimer = new() { Interval = TimeSpan.FromSeconds(30) };
|
||||
private readonly SemaphoreSlim _refreshLock = new(1, 1);
|
||||
private readonly CancellationTokenSource _controlCancellation = new();
|
||||
private bool _loaded;
|
||||
private bool _controlListening;
|
||||
|
||||
public MainPage()
|
||||
{
|
||||
InitializeComponent();
|
||||
Loaded += MainPage_Loaded;
|
||||
Unloaded += (_, _) => _controlCancellation.Cancel();
|
||||
_refreshTimer.Tick += async (_, _) => await RefreshAllAsync();
|
||||
MainNavigation.SelectedItem = MainNavigation.MenuItems[0];
|
||||
}
|
||||
|
||||
public ObservableCollection<ServerViewModel> Servers { get; } = [];
|
||||
public ObservableCollection<MeshNodeViewModel> MeshNodes { get; } = [];
|
||||
public ObservableCollection<MeshLinkViewModel> MeshLinks { get; } = [];
|
||||
|
||||
private async void MainPage_Loaded(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_loaded)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_loaded = true;
|
||||
_refreshTimer.Start();
|
||||
_history.AddRange(await _historyStorage.LoadAsync());
|
||||
foreach (var profile in await _storage.LoadAsync())
|
||||
{
|
||||
Servers.Add(new ServerViewModel(profile));
|
||||
}
|
||||
UpdateEmptyState();
|
||||
if (Servers.Count > 0)
|
||||
{
|
||||
ServerList.SelectedIndex = 0;
|
||||
RenderHistory();
|
||||
}
|
||||
|
||||
if (Servers.Count > 0)
|
||||
{
|
||||
await RefreshAllAsync();
|
||||
}
|
||||
StartControlEvents();
|
||||
}
|
||||
|
||||
private async void SshKeyButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
var publicKey = await _ssh.EnsureKeyPairAsync();
|
||||
var keyBox = new TextBox
|
||||
{
|
||||
Text = publicKey,
|
||||
IsReadOnly = true,
|
||||
AcceptsReturn = true,
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
MinWidth = 520
|
||||
};
|
||||
AutomationProperties.SetName(keyBox, "Публичный SSH-ключ мониторинга");
|
||||
var dialog = new ContentDialog
|
||||
{
|
||||
XamlRoot = XamlRoot,
|
||||
Title = "SSH-ключ мониторинга",
|
||||
Content = new StackPanel
|
||||
{
|
||||
Spacing = 12,
|
||||
Children =
|
||||
{
|
||||
new TextBlock
|
||||
{
|
||||
Text = "Скопируйте этот публичный ключ и вставьте его в установочный скрипт на каждом сервере. Приватный ключ остаётся только на этом ПК.",
|
||||
TextWrapping = TextWrapping.Wrap
|
||||
},
|
||||
keyBox
|
||||
}
|
||||
},
|
||||
PrimaryButtonText = "Копировать",
|
||||
CloseButtonText = "Закрыть",
|
||||
DefaultButton = ContentDialogButton.Primary
|
||||
};
|
||||
|
||||
if (await dialog.ShowAsync() == ContentDialogResult.Primary)
|
||||
{
|
||||
var package = new DataPackage();
|
||||
package.SetText(publicKey);
|
||||
Clipboard.SetContent(package);
|
||||
ShowInfo("SSH-ключ скопирован", "Вставьте его в ochenstarik-server-monitor-manager.sh на каждом сервере.", InfoBarSeverity.Success);
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
ShowInfo("Не удалось создать SSH-ключ", exception.Message, InfoBarSeverity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async void TerminalButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (ServerList.SelectedItem is not ServerViewModel selected)
|
||||
{
|
||||
ShowInfo("Сервер не выбран", "Выберите сервер, к которому нужно открыть SSH-терминал.", InfoBarSeverity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
var userBox = new TextBox
|
||||
{
|
||||
Header = "Unix-пользователь",
|
||||
PlaceholderText = "starik",
|
||||
MinWidth = 360
|
||||
};
|
||||
AutomationProperties.SetName(userBox, "Пользователь интерактивного SSH-терминала");
|
||||
var dialog = new ContentDialog
|
||||
{
|
||||
XamlRoot = XamlRoot,
|
||||
Title = $"SSH-терминал: {selected.Name}",
|
||||
Content = new StackPanel
|
||||
{
|
||||
Spacing = 12,
|
||||
Children =
|
||||
{
|
||||
new TextBlock
|
||||
{
|
||||
Text = "Терминал использует системный OpenSSH и ваши обычные SSH-ключи. Ключ мониторинга с ограниченной командой здесь не применяется.",
|
||||
TextWrapping = TextWrapping.Wrap
|
||||
},
|
||||
userBox
|
||||
}
|
||||
},
|
||||
PrimaryButtonText = "Открыть",
|
||||
CloseButtonText = "Отмена",
|
||||
DefaultButton = ContentDialogButton.Primary
|
||||
};
|
||||
|
||||
if (await dialog.ShowAsync() != ContentDialogResult.Primary)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_ssh.OpenInteractiveTerminal(selected.Profile, userBox.Text.Trim());
|
||||
ShowInfo("SSH-терминал открыт", $"Подключение к {selected.Profile.Host} запущено от имени {userBox.Text.Trim()}.", InfoBarSeverity.Success);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
ShowInfo("Не удалось открыть SSH-терминал", exception.Message, InfoBarSeverity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async void ControlHubButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var codeBox = new TextBox
|
||||
{
|
||||
Header = "Код устройства SMMDEV1",
|
||||
PlaceholderText = "SMMDEV1-...",
|
||||
AcceptsReturn = true,
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
MinWidth = 360
|
||||
};
|
||||
AutomationProperties.SetName(codeBox, "Одноразовый код Control Hub для Windows-устройства");
|
||||
var codeDialog = new ContentDialog
|
||||
{
|
||||
XamlRoot = XamlRoot,
|
||||
Title = "Подключение Control Hub",
|
||||
Content = new StackPanel
|
||||
{
|
||||
Spacing = 12,
|
||||
Children =
|
||||
{
|
||||
new TextBlock
|
||||
{
|
||||
Text = "На Hub выполните: sudo ochenstarik-server-monitor-manager.sh control-device-code windows-pc. Вставьте полученный одноразовый код.",
|
||||
TextWrapping = TextWrapping.Wrap
|
||||
},
|
||||
codeBox
|
||||
}
|
||||
},
|
||||
PrimaryButtonText = "Проверить",
|
||||
CloseButtonText = "Отмена",
|
||||
DefaultButton = ContentDialogButton.Primary
|
||||
};
|
||||
if (await codeDialog.ShowAsync() != ContentDialogResult.Primary)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var preview = _control.ParseEnrollmentCode(codeBox.Text);
|
||||
var fingerprintBox = new TextBox
|
||||
{
|
||||
Header = "SHA-256 fingerprint Control CA",
|
||||
Text = preview.Fingerprint,
|
||||
IsReadOnly = true,
|
||||
TextWrapping = TextWrapping.Wrap
|
||||
};
|
||||
AutomationProperties.SetName(fingerprintBox, "SHA-256 fingerprint Control CA");
|
||||
var confirmDialog = new ContentDialog
|
||||
{
|
||||
XamlRoot = XamlRoot,
|
||||
Title = "Подтвердите Control Hub",
|
||||
Content = new StackPanel
|
||||
{
|
||||
Spacing = 12,
|
||||
Children =
|
||||
{
|
||||
new TextBlock
|
||||
{
|
||||
Text = $"Устройство: {preview.DeviceId}\nHub: {preview.ControlUrl}",
|
||||
TextWrapping = TextWrapping.Wrap
|
||||
},
|
||||
fingerprintBox,
|
||||
new TextBlock
|
||||
{
|
||||
Text = "Сравните fingerprint с показанным на Hub. После подтверждения приложение локально создаст отдельный operator key и защитит его через Windows DPAPI.",
|
||||
TextWrapping = TextWrapping.Wrap
|
||||
}
|
||||
}
|
||||
},
|
||||
PrimaryButtonText = "Fingerprint совпадает",
|
||||
CloseButtonText = "Отмена",
|
||||
DefaultButton = ContentDialogButton.Close
|
||||
};
|
||||
if (await confirmDialog.ShowAsync() != ContentDialogResult.Primary)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
|
||||
await _control.EnrollAsync(preview, timeout.Token);
|
||||
ShowInfo(
|
||||
"Control Hub подключён",
|
||||
$"Operator identity {preview.DeviceId} сохранена через DPAPI. Поток событий запускается.",
|
||||
InfoBarSeverity.Success);
|
||||
StartControlEvents();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
ShowInfo("Не удалось подключить Control Hub", CompactError(exception), InfoBarSeverity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void StartControlEvents()
|
||||
{
|
||||
if (_controlListening || _controlCancellation.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
_controlListening = true;
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await _control.ListenAsync(HandleControlEventAsync, _controlCancellation.Token);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_controlListening = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private Task HandleControlEventAsync(ControlEvent controlEvent)
|
||||
{
|
||||
DispatcherQueue.TryEnqueue(() =>
|
||||
{
|
||||
if (controlEvent.Type.StartsWith("link.", StringComparison.Ordinal))
|
||||
{
|
||||
ShowInfo(
|
||||
"Событие Control Hub",
|
||||
$"{controlEvent.Subject}: {controlEvent.Type}",
|
||||
controlEvent.Type is "link.failed" or "link.partial"
|
||||
? InfoBarSeverity.Warning
|
||||
: InfoBarSeverity.Informational);
|
||||
_ = RefreshMeshAsync(showSuccess: false);
|
||||
}
|
||||
});
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async void AddServerButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _ssh.EnsureKeyPairAsync();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
ShowInfo("Не удалось подготовить SSH-ключ", exception.Message, InfoBarSeverity.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
var nameBox = new TextBox { Header = "Название", PlaceholderText = "Home Lab" };
|
||||
var hostBox = new TextBox { Header = "IP или домен", PlaceholderText = "192.0.2.10" };
|
||||
var portBox = new NumberBox
|
||||
{
|
||||
Header = "SSH-порт",
|
||||
Value = 22,
|
||||
Minimum = 1,
|
||||
Maximum = 65535,
|
||||
SpinButtonPlacementMode = NumberBoxSpinButtonPlacementMode.Compact
|
||||
};
|
||||
var userBox = new TextBox { Header = "Пользователь", Text = "ochenstarik-monitor" };
|
||||
var hubBox = new CheckBox
|
||||
{
|
||||
Content = "Это главный Mesh Hub"
|
||||
};
|
||||
AutomationProperties.SetName(hubBox, "Использовать сервер как главный Mesh Hub");
|
||||
var validationText = new TextBlock { TextWrapping = TextWrapping.Wrap };
|
||||
var content = new StackPanel
|
||||
{
|
||||
Spacing = 12,
|
||||
MinWidth = 420,
|
||||
Children = { nameBox, hostBox, portBox, userBox, hubBox, validationText }
|
||||
};
|
||||
var dialog = new ContentDialog
|
||||
{
|
||||
XamlRoot = XamlRoot,
|
||||
Title = "Добавить сервер",
|
||||
Content = content,
|
||||
PrimaryButtonText = "Добавить и проверить",
|
||||
CloseButtonText = "Отмена",
|
||||
DefaultButton = ContentDialogButton.Primary
|
||||
};
|
||||
dialog.PrimaryButtonClick += (_, args) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(nameBox.Text)
|
||||
|| string.IsNullOrWhiteSpace(hostBox.Text)
|
||||
|| string.IsNullOrWhiteSpace(userBox.Text)
|
||||
|| double.IsNaN(portBox.Value))
|
||||
{
|
||||
validationText.Text = "Заполните название, адрес, порт и пользователя.";
|
||||
args.Cancel = true;
|
||||
}
|
||||
};
|
||||
|
||||
if (await dialog.ShowAsync() != ContentDialogResult.Primary)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (hubBox.IsChecked == true && Servers.Any(server => server.IsHub))
|
||||
{
|
||||
ShowInfo("Hub уже выбран", "Измените существующий Hub или снимите эту отметку.", InfoBarSeverity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
var profile = new ServerProfileData(
|
||||
Guid.NewGuid().ToString("N"),
|
||||
nameBox.Text.Trim(),
|
||||
hostBox.Text.Trim(),
|
||||
checked((int)portBox.Value),
|
||||
userBox.Text.Trim(),
|
||||
hubBox.IsChecked == true);
|
||||
var server = new ServerViewModel(profile);
|
||||
Servers.Add(server);
|
||||
await SaveProfilesAsync();
|
||||
UpdateEmptyState();
|
||||
await RefreshServerAsync(server);
|
||||
}
|
||||
|
||||
private async void EditServerButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (ServerList.SelectedItem is not ServerViewModel selected)
|
||||
{
|
||||
ShowInfo("Сервер не выбран", "Выберите сервер в списке для изменения.", InfoBarSeverity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
var nameBox = new TextBox { Header = "Название", Text = selected.Profile.Name };
|
||||
var hostBox = new TextBox { Header = "IP или домен", Text = selected.Profile.Host };
|
||||
var portBox = new NumberBox
|
||||
{
|
||||
Header = "SSH-порт",
|
||||
Value = selected.Profile.Port,
|
||||
Minimum = 1,
|
||||
Maximum = 65535,
|
||||
SpinButtonPlacementMode = NumberBoxSpinButtonPlacementMode.Compact
|
||||
};
|
||||
var userBox = new TextBox { Header = "Пользователь", Text = selected.Profile.User };
|
||||
var hubBox = new CheckBox { Content = "Это главный Mesh Hub", IsChecked = selected.IsHub };
|
||||
AutomationProperties.SetName(hubBox, "Использовать сервер как главный Mesh Hub");
|
||||
var dialog = new ContentDialog
|
||||
{
|
||||
XamlRoot = XamlRoot,
|
||||
Title = "Изменить сервер",
|
||||
Content = new StackPanel
|
||||
{
|
||||
Spacing = 12,
|
||||
MinWidth = 420,
|
||||
Children = { nameBox, hostBox, portBox, userBox, hubBox }
|
||||
},
|
||||
PrimaryButtonText = "Сохранить",
|
||||
CloseButtonText = "Отмена",
|
||||
DefaultButton = ContentDialogButton.Primary
|
||||
};
|
||||
if (await dialog.ShowAsync() != ContentDialogResult.Primary)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(nameBox.Text)
|
||||
|| string.IsNullOrWhiteSpace(hostBox.Text)
|
||||
|| string.IsNullOrWhiteSpace(userBox.Text)
|
||||
|| double.IsNaN(portBox.Value))
|
||||
{
|
||||
ShowInfo("Данные не сохранены", "Название, адрес, порт и пользователь обязательны.", InfoBarSeverity.Warning);
|
||||
return;
|
||||
}
|
||||
if (hubBox.IsChecked == true && Servers.Any(server => server.IsHub && server != selected))
|
||||
{
|
||||
ShowInfo("Hub уже выбран", "В конфигурации может быть только один главный Mesh Hub.", InfoBarSeverity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
var index = Servers.IndexOf(selected);
|
||||
var updated = new ServerViewModel(new ServerProfileData(
|
||||
selected.Profile.Id,
|
||||
nameBox.Text.Trim(),
|
||||
hostBox.Text.Trim(),
|
||||
checked((int)portBox.Value),
|
||||
userBox.Text.Trim(),
|
||||
hubBox.IsChecked == true));
|
||||
Servers[index] = updated;
|
||||
await SaveProfilesAsync();
|
||||
await RefreshServerAsync(updated);
|
||||
ShowInfo("Сервер изменён", updated.Name, InfoBarSeverity.Success);
|
||||
}
|
||||
|
||||
private async void DeleteServerButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (ServerList.SelectedItem is not ServerViewModel selected)
|
||||
{
|
||||
ShowInfo("Сервер не выбран", "Выберите сервер в списке для удаления.", InfoBarSeverity.Warning);
|
||||
return;
|
||||
}
|
||||
var dialog = new ContentDialog
|
||||
{
|
||||
XamlRoot = XamlRoot,
|
||||
Title = $"Удалить {selected.Name}?",
|
||||
Content = "Удаляется только локальный профиль. Серверная часть и WireGuard Node останутся установленными.",
|
||||
PrimaryButtonText = "Удалить профиль",
|
||||
CloseButtonText = "Отмена",
|
||||
DefaultButton = ContentDialogButton.Close
|
||||
};
|
||||
if (await dialog.ShowAsync() != ContentDialogResult.Primary)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Servers.Remove(selected);
|
||||
_history.RemoveAll(sample => sample.ServerId == selected.Profile.Id);
|
||||
await SaveProfilesAsync();
|
||||
await _historyStorage.SaveAsync(_history);
|
||||
UpdateEmptyState();
|
||||
ShowInfo("Профиль удалён", selected.Name, InfoBarSeverity.Success);
|
||||
}
|
||||
|
||||
private async void RefreshButton_Click(object sender, RoutedEventArgs e)
|
||||
=> await RefreshAllAsync();
|
||||
|
||||
private async Task RefreshAllAsync()
|
||||
{
|
||||
if (!await _refreshLock.WaitAsync(0))
|
||||
{
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
if (Servers.Count == 0)
|
||||
{
|
||||
ShowInfo("Серверы не добавлены", "Сначала создайте SSH-ключ и установите его на сервере.", InfoBarSeverity.Informational);
|
||||
return;
|
||||
}
|
||||
|
||||
await Task.WhenAll(Servers.Select(RefreshServerAsync));
|
||||
await _historyStorage.SaveAsync(_history);
|
||||
var online = Servers.Count(server => server.IsOnline);
|
||||
var warnings = Servers.Count(server => !server.IsOnline || server.HasWarning);
|
||||
AvailabilityValueText.Text = $"{online} / {Servers.Count}";
|
||||
AvailabilityDetailText.Text = warnings == 0 ? "Все серверы доступны" : $"Недоступно: {warnings}";
|
||||
AverageLoadValueText.Text = online == 0
|
||||
? "—"
|
||||
: $"{Servers.Where(server => server.IsOnline).Average(server => server.CpuPercent):F0}%";
|
||||
WarningValueText.Text = warnings.ToString(CultureInfo.InvariantCulture);
|
||||
WarningDetailText.Text = warnings == 0 ? "Нет предупреждений" : "Проверьте доступность и ресурсы";
|
||||
HeaderStatusText.Text = $"SSH monitoring · {Servers.Count} сервер(а) · обновлено {DateTime.Now:HH:mm:ss}";
|
||||
if (Servers.Any(server => server.IsHub) || _control.IsConfigured)
|
||||
{
|
||||
await RefreshMeshAsync(showSuccess: false);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_refreshLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RefreshServerAsync(ServerViewModel server)
|
||||
{
|
||||
server.Status = "Подключение…";
|
||||
try
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15));
|
||||
var metrics = await _ssh.QueryAsync(server.Profile, timeout.Token);
|
||||
server.CpuPercent = metrics.CpuPercent;
|
||||
server.CpuText = $"{metrics.CpuPercent:F0}% load";
|
||||
server.MemoryText = $"{FormatSize(metrics.MemoryUsedKb)} / {FormatSize(metrics.MemoryTotalKb)} RAM";
|
||||
server.DiskText = $"{FormatSize(metrics.DiskUsedKb)} / {FormatSize(metrics.DiskTotalKb)} disk";
|
||||
var memoryPercent = Percent(metrics.MemoryUsedKb, metrics.MemoryTotalKb);
|
||||
var diskPercent = Percent(metrics.DiskUsedKb, metrics.DiskTotalKb);
|
||||
var inodePercent = Percent(metrics.InodesUsed, metrics.InodesTotal);
|
||||
server.HasWarning = memoryPercent >= 90 || diskPercent >= 90 || inodePercent >= 90 || metrics.SshState != "active";
|
||||
server.HealthText = $"swap {FormatSize(metrics.SwapUsedKb)}/{FormatSize(metrics.SwapTotalKb)} · inode {inodePercent:F0}% · net ↓{FormatBytes(metrics.NetworkRxBytes)} ↑{FormatBytes(metrics.NetworkTxBytes)} · SSH {metrics.SshState}";
|
||||
server.LatencyText = $"{metrics.Latency.TotalMilliseconds:F0} ms";
|
||||
server.Status = $"Онлайн · uptime {FormatUptime(metrics.Uptime)}";
|
||||
server.IsOnline = true;
|
||||
_history.Add(new MetricSampleData(
|
||||
server.Profile.Id,
|
||||
DateTimeOffset.Now,
|
||||
metrics.CpuPercent,
|
||||
memoryPercent,
|
||||
diskPercent));
|
||||
var overflow = _history
|
||||
.Where(sample => sample.ServerId == server.Profile.Id)
|
||||
.OrderByDescending(sample => sample.Timestamp)
|
||||
.Skip(240)
|
||||
.ToList();
|
||||
foreach (var sample in overflow)
|
||||
{
|
||||
_history.Remove(sample);
|
||||
}
|
||||
if (ServerList.SelectedItem == server)
|
||||
{
|
||||
RenderHistory();
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
server.Status = CompactError(exception);
|
||||
server.CpuText = "—";
|
||||
server.CpuPercent = 0;
|
||||
server.MemoryText = "—";
|
||||
server.DiskText = "—";
|
||||
server.LatencyText = "offline";
|
||||
server.IsOnline = false;
|
||||
server.HasWarning = true;
|
||||
server.HealthText = "Нет данных о службах и сети";
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SaveProfilesAsync()
|
||||
=> await _storage.SaveAsync(Servers.Select(server => server.Profile));
|
||||
|
||||
private void UpdateEmptyState()
|
||||
{
|
||||
EmptyServersText.Visibility = Servers.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
|
||||
HeaderStatusText.Text = Servers.Count == 0
|
||||
? "SSH monitoring · серверы ещё не добавлены"
|
||||
: $"SSH monitoring · {Servers.Count} сервер(а)";
|
||||
if (Servers.Count == 0)
|
||||
{
|
||||
AvailabilityValueText.Text = "0";
|
||||
AvailabilityDetailText.Text = "Добавьте серверы";
|
||||
AverageLoadValueText.Text = "—";
|
||||
WarningValueText.Text = "0";
|
||||
WarningDetailText.Text = "Нет данных";
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatSize(long kilobytes)
|
||||
=> kilobytes >= 1024 * 1024
|
||||
? $"{kilobytes / 1024d / 1024d:F1} GB"
|
||||
: $"{kilobytes / 1024d:F0} MB";
|
||||
|
||||
private static string FormatUptime(TimeSpan uptime)
|
||||
=> uptime.TotalDays >= 1
|
||||
? $"{uptime.TotalDays:F0} дн."
|
||||
: $"{uptime.TotalHours:F0} ч.";
|
||||
|
||||
private static double Percent(long used, long total)
|
||||
=> total <= 0 ? 0 : Math.Clamp(used * 100d / total, 0, 100);
|
||||
|
||||
private static string FormatBytes(long bytes)
|
||||
=> bytes >= 1024L * 1024 * 1024
|
||||
? $"{bytes / 1024d / 1024d / 1024d:F1} GB"
|
||||
: $"{bytes / 1024d / 1024d:F1} MB";
|
||||
|
||||
private void ServerList_SelectionChanged(object sender, SelectionChangedEventArgs e)
|
||||
=> RenderHistory();
|
||||
|
||||
private void HistoryChart_SizeChanged(object sender, SizeChangedEventArgs e)
|
||||
=> RenderHistory();
|
||||
|
||||
private void RenderHistory()
|
||||
{
|
||||
if (ServerList.SelectedItem is not ServerViewModel server)
|
||||
{
|
||||
HistoryCaptionText.Text = "Выберите сервер";
|
||||
CpuHistoryLine.Points = new PointCollection();
|
||||
MemoryHistoryLine.Points = new PointCollection();
|
||||
DiskHistoryLine.Points = new PointCollection();
|
||||
return;
|
||||
}
|
||||
|
||||
var samples = _history
|
||||
.Where(sample => sample.ServerId == server.Profile.Id)
|
||||
.OrderBy(sample => sample.Timestamp)
|
||||
.ToList();
|
||||
HistoryCaptionText.Text = samples.Count == 0
|
||||
? $"{server.Name} · данные появятся после обновления"
|
||||
: $"{server.Name} · {samples.Count} точек · {samples[0].Timestamp:dd.MM HH:mm} — {samples[^1].Timestamp:dd.MM HH:mm}";
|
||||
CpuHistoryLine.Points = BuildHistoryPoints(samples.Select(sample => sample.CpuPercent).ToList());
|
||||
MemoryHistoryLine.Points = BuildHistoryPoints(samples.Select(sample => sample.MemoryPercent).ToList());
|
||||
DiskHistoryLine.Points = BuildHistoryPoints(samples.Select(sample => sample.DiskPercent).ToList());
|
||||
}
|
||||
|
||||
private PointCollection BuildHistoryPoints(IReadOnlyList<double> values)
|
||||
{
|
||||
var points = new PointCollection();
|
||||
if (values.Count == 0 || HistoryChart.ActualWidth <= 0 || HistoryChart.ActualHeight <= 0)
|
||||
{
|
||||
return points;
|
||||
}
|
||||
var step = values.Count == 1 ? 0 : HistoryChart.ActualWidth / (values.Count - 1);
|
||||
for (var index = 0; index < values.Count; index++)
|
||||
{
|
||||
var x = values.Count == 1 ? HistoryChart.ActualWidth : index * step;
|
||||
var y = HistoryChart.ActualHeight * (1 - Math.Clamp(values[index], 0, 100) / 100d);
|
||||
points.Add(new Point(x, y));
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
private static string CompactError(Exception exception)
|
||||
{
|
||||
var firstLine = exception.Message.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries).FirstOrDefault();
|
||||
return string.IsNullOrWhiteSpace(firstLine) ? "Ошибка подключения" : firstLine;
|
||||
}
|
||||
|
||||
private void ShowInfo(string title, string message, InfoBarSeverity severity)
|
||||
{
|
||||
LinkActionInfo.Title = title;
|
||||
LinkActionInfo.Message = message;
|
||||
LinkActionInfo.Severity = severity;
|
||||
LinkActionInfo.IsOpen = true;
|
||||
}
|
||||
|
||||
private ServerViewModel? FindHub()
|
||||
=> Servers.FirstOrDefault(server => server.IsHub);
|
||||
|
||||
private async Task RefreshMeshAsync(bool showSuccess = true)
|
||||
{
|
||||
if (_control.IsConfigured)
|
||||
{
|
||||
await RefreshControlMeshAsync(showSuccess);
|
||||
return;
|
||||
}
|
||||
|
||||
var hub = FindHub();
|
||||
if (hub is null)
|
||||
{
|
||||
ShowInfo("Mesh Hub не выбран", "Добавьте главный сервер или отметьте его как Mesh Hub.", InfoBarSeverity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15));
|
||||
var nodesOutput = await _ssh.RunRestrictedCommandAsync(hub.Profile, "mesh nodes", timeout.Token);
|
||||
var linksOutput = await _ssh.RunRestrictedCommandAsync(hub.Profile, "mesh links", timeout.Token);
|
||||
MeshNodes.Clear();
|
||||
foreach (var line in nodesOutput.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
if (!line.StartsWith("NODE=", StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var fields = line[5..].Split('|');
|
||||
if (fields.Length == 4 && int.TryParse(fields[3], out var age))
|
||||
{
|
||||
MeshNodes.Add(new MeshNodeViewModel(fields[0], fields[1], fields[2], age));
|
||||
}
|
||||
}
|
||||
|
||||
MeshLinks.Clear();
|
||||
foreach (var line in linksOutput.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
if (!line.StartsWith("LINK=", StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var fields = line[5..].Split('|');
|
||||
if (fields.Length >= 8
|
||||
&& int.TryParse(fields[4], CultureInfo.InvariantCulture, out var port)
|
||||
&& long.TryParse(fields[5], CultureInfo.InvariantCulture, out var expiresUnix)
|
||||
&& long.TryParse(fields[7], CultureInfo.InvariantCulture, out var version))
|
||||
{
|
||||
MeshLinks.Add(new MeshLinkViewModel(
|
||||
fields[0],
|
||||
fields[1],
|
||||
fields[2],
|
||||
fields[3],
|
||||
port,
|
||||
expiresUnix,
|
||||
fields[6],
|
||||
version));
|
||||
}
|
||||
}
|
||||
|
||||
ActiveLinksValueText.Text = MeshLinks.Count.ToString(CultureInfo.InvariantCulture);
|
||||
MeshStatusText.Text = $"{MeshNodes.Count} узлов · {MeshLinks.Count} активных связей";
|
||||
if (showSuccess)
|
||||
{
|
||||
ShowInfo("Mesh обновлён", MeshStatusText.Text, InfoBarSeverity.Success);
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
MeshStatusText.Text = "Hub недоступен";
|
||||
ShowInfo("Не удалось получить Mesh-состояние", CompactError(exception), InfoBarSeverity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RefreshControlMeshAsync(bool showSuccess)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15));
|
||||
var agents = await _control.GetAgentsAsync(timeout.Token);
|
||||
var links = await _control.GetLinksAsync(timeout.Token);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
MeshNodes.Clear();
|
||||
foreach (var agent in agents)
|
||||
{
|
||||
var age = agent.LastSeenAt is null
|
||||
? int.MaxValue
|
||||
: (int)Math.Clamp((now - agent.LastSeenAt.Value).TotalSeconds, 0, int.MaxValue);
|
||||
MeshNodes.Add(new MeshNodeViewModel(
|
||||
agent.NodeId,
|
||||
"Control",
|
||||
age <= 90 ? "online" : "offline",
|
||||
age));
|
||||
}
|
||||
|
||||
MeshLinks.Clear();
|
||||
foreach (var link in links.Where(link =>
|
||||
link.DesiredState != "Disabled" || link.ActualState == "Partial"))
|
||||
{
|
||||
MeshLinks.Add(new MeshLinkViewModel(
|
||||
link.SourceNodeId,
|
||||
link.TargetNodeId,
|
||||
link.TargetNodeId,
|
||||
link.Protocol,
|
||||
link.Port,
|
||||
link.ExpiresAt?.ToUnixTimeSeconds() ?? 0,
|
||||
link.ActualState,
|
||||
link.Version,
|
||||
link.Id));
|
||||
}
|
||||
|
||||
ActiveLinksValueText.Text = MeshLinks.Count.ToString(CultureInfo.InvariantCulture);
|
||||
MeshStatusText.Text = $"Control · {MeshNodes.Count} узлов · {MeshLinks.Count} активных связей";
|
||||
if (showSuccess)
|
||||
{
|
||||
ShowInfo("Control Hub обновлён", MeshStatusText.Text, InfoBarSeverity.Success);
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
MeshStatusText.Text = "Control Hub недоступен";
|
||||
ShowInfo("Не удалось получить Control-состояние", CompactError(exception), InfoBarSeverity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async void RefreshMeshButton_Click(object sender, RoutedEventArgs e)
|
||||
=> await RefreshMeshAsync();
|
||||
|
||||
private async void ConnectLinkButton_Click(object sender, RoutedEventArgs e)
|
||||
=> await ChangeLinkAsync(enable: true);
|
||||
|
||||
private async void DisconnectLinkButton_Click(object sender, RoutedEventArgs e)
|
||||
=> await ChangeLinkAsync(enable: false);
|
||||
|
||||
private async Task ChangeLinkAsync(bool enable)
|
||||
{
|
||||
var useControl = _control.IsConfigured;
|
||||
var hub = FindHub();
|
||||
if (!useControl && hub is null)
|
||||
{
|
||||
ShowInfo("Mesh Hub не выбран", "Сначала добавьте главный сервер с отметкой Mesh Hub.", InfoBarSeverity.Warning);
|
||||
return;
|
||||
}
|
||||
MeshNodeViewModel? source = null;
|
||||
MeshNodeViewModel? target = null;
|
||||
string protocol;
|
||||
int port;
|
||||
int ttlMinutes;
|
||||
|
||||
if (enable)
|
||||
{
|
||||
if (SourceNodeBox.SelectedItem is not MeshNodeViewModel selectedSource
|
||||
|| TargetNodeBox.SelectedItem is not MeshNodeViewModel selectedTarget)
|
||||
{
|
||||
ShowInfo("Выберите серверы", "Укажите источник и сервер назначения.", InfoBarSeverity.Warning);
|
||||
return;
|
||||
}
|
||||
source = selectedSource;
|
||||
target = selectedTarget;
|
||||
protocol = (LinkProtocolBox.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "tcp";
|
||||
if (double.IsNaN(LinkPortBox.Value)
|
||||
|| double.IsNaN(LinkTtlBox.Value)
|
||||
|| LinkPortBox.Value is < 1 or > 65535
|
||||
|| LinkTtlBox.Value is < 0 or > 525600)
|
||||
{
|
||||
ShowInfo("Некорректная политика", "Проверьте порт и TTL.", InfoBarSeverity.Warning);
|
||||
return;
|
||||
}
|
||||
port = checked((int)LinkPortBox.Value);
|
||||
ttlMinutes = checked((int)LinkTtlBox.Value);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (MeshLinksList.SelectedItem is not MeshLinkViewModel selectedLink)
|
||||
{
|
||||
ShowInfo("Выберите связь", "Для отключения выберите правило в списке.", InfoBarSeverity.Warning);
|
||||
return;
|
||||
}
|
||||
source = MeshNodes.FirstOrDefault(node => node.Name == selectedLink.Source);
|
||||
target = MeshNodes.FirstOrDefault(node => node.Name == selectedLink.Target);
|
||||
if (source is null || target is null)
|
||||
{
|
||||
ShowInfo("Узел не найден", "Обновите список Mesh и повторите попытку.", InfoBarSeverity.Warning);
|
||||
return;
|
||||
}
|
||||
protocol = selectedLink.Protocol;
|
||||
port = selectedLink.Port;
|
||||
ttlMinutes = 0;
|
||||
}
|
||||
|
||||
if (source.Name == target.Name)
|
||||
{
|
||||
ShowInfo("Некорректная связь", "Источник и назначение должны отличаться.", InfoBarSeverity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (useControl)
|
||||
{
|
||||
using var controlTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(20));
|
||||
LinkPolicy link;
|
||||
if (enable)
|
||||
{
|
||||
link = await _control.CreateLinkAsync(
|
||||
new LinkPolicyCreateRequest(
|
||||
source.Name,
|
||||
target.Name,
|
||||
protocol,
|
||||
port,
|
||||
ttlMinutes,
|
||||
"Windows client",
|
||||
Guid.NewGuid().ToString()),
|
||||
controlTimeout.Token);
|
||||
}
|
||||
else
|
||||
{
|
||||
var selectedLink = (MeshLinkViewModel)MeshLinksList.SelectedItem;
|
||||
if (string.IsNullOrWhiteSpace(selectedLink.Id))
|
||||
{
|
||||
throw new InvalidOperationException("У выбранного Link отсутствует Control id.");
|
||||
}
|
||||
link = await _control.DisableLinkAsync(selectedLink.Id, controlTimeout.Token);
|
||||
}
|
||||
await RefreshMeshAsync(showSuccess: false);
|
||||
ShowInfo(
|
||||
enable ? "Control Link создан" : "Control Link отключён",
|
||||
$"{source.Name} → {target.Name} · {protocol.ToUpperInvariant()}/{port} · {link.ActualState} v{link.Version}",
|
||||
link.ActualState is "Failed" or "Partial"
|
||||
? InfoBarSeverity.Warning
|
||||
: InfoBarSeverity.Success);
|
||||
return;
|
||||
}
|
||||
|
||||
var action = enable ? "connect" : "disconnect";
|
||||
var policyArguments = enable
|
||||
? $"{protocol} {port} {ttlMinutes}"
|
||||
: $"{protocol} {port}";
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15));
|
||||
var commandOutput = await _ssh.RunRestrictedCommandAsync(
|
||||
hub!.Profile,
|
||||
$"mesh {action} {source.Name} {target.Name} {policyArguments}",
|
||||
timeout.Token);
|
||||
var confirmation = commandOutput
|
||||
.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)
|
||||
.FirstOrDefault(line => line.StartsWith("LINK_STATE=", StringComparison.Ordinal));
|
||||
if (confirmation is null)
|
||||
{
|
||||
throw new InvalidOperationException("Hub не подтвердил фактическое состояние Link.");
|
||||
}
|
||||
await RefreshMeshAsync(showSuccess: false);
|
||||
ShowInfo(
|
||||
enable ? "Hub подтвердил Active" : "Hub подтвердил Disabled",
|
||||
$"{source.Name} → {target.Name} · {protocol.ToUpperInvariant()}/{port} · {confirmation[11..]}",
|
||||
enable ? InfoBarSeverity.Success : InfoBarSeverity.Warning);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
ShowInfo("Не удалось изменить связь", CompactError(exception), InfoBarSeverity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void MainNavigation_SelectionChanged(NavigationView sender, NavigationViewSelectionChangedEventArgs args)
|
||||
{
|
||||
if (args.IsSettingsSelected)
|
||||
{
|
||||
ShowPlaceholder(
|
||||
"Настройки",
|
||||
"Профили серверов хранятся локально. Настройки ключей, интервалов обновления и подтверждений будут добавляться здесь.");
|
||||
return;
|
||||
}
|
||||
|
||||
var tag = (args.SelectedItem as NavigationViewItem)?.Tag?.ToString() ?? "overview";
|
||||
NavigationPlaceholder.Visibility = Visibility.Collapsed;
|
||||
OverviewSummary.Visibility = Visibility.Visible;
|
||||
WorkspaceScroll.Visibility = Visibility.Visible;
|
||||
ServerWorkspace.Visibility = tag is "overview" or "servers" ? Visibility.Visible : Visibility.Collapsed;
|
||||
LinkInspector.Visibility = tag is "overview" or "links" ? Visibility.Visible : Visibility.Collapsed;
|
||||
InspectorColumn.Width = tag == "overview" && ActualWidth >= 1080
|
||||
? new GridLength(360)
|
||||
: new GridLength(0);
|
||||
|
||||
if (tag == "links")
|
||||
{
|
||||
Grid.SetRow(LinkInspector, 0);
|
||||
Grid.SetColumn(LinkInspector, 0);
|
||||
Grid.SetColumnSpan(LinkInspector, 2);
|
||||
}
|
||||
else
|
||||
{
|
||||
Grid.SetColumnSpan(LinkInspector, 1);
|
||||
if (ActualWidth >= 1080)
|
||||
{
|
||||
Grid.SetRow(LinkInspector, 0);
|
||||
Grid.SetColumn(LinkInspector, 1);
|
||||
}
|
||||
else
|
||||
{
|
||||
Grid.SetRow(LinkInspector, 1);
|
||||
Grid.SetColumn(LinkInspector, 0);
|
||||
}
|
||||
}
|
||||
|
||||
if (tag == "sessions")
|
||||
{
|
||||
ShowPlaceholder(
|
||||
"SSH-сессии",
|
||||
"Интерактивные терминалы будут использовать отдельную identity и не получат ключ мониторинга или права Mesh Hub.");
|
||||
}
|
||||
}
|
||||
|
||||
private void ShowPlaceholder(string title, string description)
|
||||
{
|
||||
OverviewSummary.Visibility = Visibility.Collapsed;
|
||||
WorkspaceScroll.Visibility = Visibility.Collapsed;
|
||||
PlaceholderTitle.Text = title;
|
||||
PlaceholderDescription.Text = description;
|
||||
NavigationPlaceholder.Visibility = Visibility.Visible;
|
||||
}
|
||||
}
|
||||
34
src/ServerMonitorManager.Desktop/MainWindow.xaml
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<Window
|
||||
x:Class="ServerMonitorManager_Desktop.MainWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:local="using:ServerMonitorManager_Desktop"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
Title="Server Monitor Manager"
|
||||
mc:Ignorable="d">
|
||||
<Window.SystemBackdrop>
|
||||
<MicaBackdrop />
|
||||
</Window.SystemBackdrop>
|
||||
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<TitleBar x:Name="AppTitleBar" Title="Server Monitor Manager">
|
||||
<TitleBar.IconSource>
|
||||
<ImageIconSource ImageSource="Assets/AppIcon.ico" />
|
||||
</TitleBar.IconSource>
|
||||
</TitleBar>
|
||||
|
||||
<!--
|
||||
The Frame hosts pages for your application content. Add your UI to
|
||||
MainPage.xaml rather than here so you can use Page features such as
|
||||
navigation events and the Loaded lifecycle.
|
||||
-->
|
||||
<Frame x:Name="RootFrame" Grid.Row="1" />
|
||||
</Grid>
|
||||
</Window>
|
||||
29
src/ServerMonitorManager.Desktop/MainWindow.xaml.cs
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
using Microsoft.UI.Xaml;
|
||||
using Windows.Graphics;
|
||||
|
||||
// To learn more about WinUI, the WinUI project structure,
|
||||
// and more about our project templates, see: http://aka.ms/winui-project-info.
|
||||
|
||||
namespace ServerMonitorManager_Desktop;
|
||||
|
||||
/// <summary>
|
||||
/// The application window. This hosts a Frame that displays pages. Add your
|
||||
/// UI and logic to MainPage.xaml / MainPage.xaml.cs instead of here so you
|
||||
/// can use Page features such as navigation events and the Loaded lifecycle.
|
||||
/// </summary>
|
||||
public sealed partial class MainWindow : Window
|
||||
{
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
ExtendsContentIntoTitleBar = true;
|
||||
SetTitleBar(AppTitleBar);
|
||||
|
||||
AppWindow.SetIcon("Assets/AppIcon.ico");
|
||||
AppWindow.Resize(new SizeInt32(1280, 820));
|
||||
|
||||
// Navigate the root frame to the main page on startup.
|
||||
RootFrame.Navigate(typeof(MainPage));
|
||||
}
|
||||
}
|
||||
57
src/ServerMonitorManager.Desktop/MeshModels.cs
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
namespace ServerMonitorManager_Desktop;
|
||||
|
||||
public sealed class MeshNodeViewModel
|
||||
{
|
||||
public MeshNodeViewModel(string name, string address, string state, int handshakeAgeSeconds)
|
||||
{
|
||||
Name = name;
|
||||
Address = address;
|
||||
State = state;
|
||||
HandshakeAgeSeconds = handshakeAgeSeconds;
|
||||
}
|
||||
|
||||
public string Name { get; set; }
|
||||
public string Address { get; set; }
|
||||
public string State { get; set; }
|
||||
public int HandshakeAgeSeconds { get; set; }
|
||||
public string Label => $"{Name} · {Address} · {(State == "online" ? "онлайн" : "не в сети")}";
|
||||
}
|
||||
|
||||
public sealed class MeshLinkViewModel
|
||||
{
|
||||
public MeshLinkViewModel(
|
||||
string source,
|
||||
string target,
|
||||
string cidr,
|
||||
string protocol,
|
||||
int port,
|
||||
long expiresUnix,
|
||||
string state,
|
||||
long version,
|
||||
string? id = null)
|
||||
{
|
||||
Source = source;
|
||||
Target = target;
|
||||
Cidr = cidr;
|
||||
Protocol = protocol;
|
||||
Port = port;
|
||||
ExpiresUnix = expiresUnix;
|
||||
State = state;
|
||||
Version = version;
|
||||
Id = id;
|
||||
}
|
||||
|
||||
public string Source { get; set; }
|
||||
public string Target { get; set; }
|
||||
public string Cidr { get; set; }
|
||||
public string Protocol { get; set; }
|
||||
public int Port { get; set; }
|
||||
public long ExpiresUnix { get; set; }
|
||||
public string State { get; set; }
|
||||
public long Version { get; set; }
|
||||
public string? Id { get; set; }
|
||||
public string ExpirationText => ExpiresUnix == 0
|
||||
? "вручную"
|
||||
: $"до {DateTimeOffset.FromUnixTimeSeconds(ExpiresUnix).ToLocalTime():dd.MM HH:mm}";
|
||||
public string Label => $"{Source} → {Target} · {Protocol.ToUpperInvariant()}/{Port} · {Cidr} · {ExpirationText} · {State} v{Version}";
|
||||
}
|
||||
49
src/ServerMonitorManager.Desktop/MetricsHistoryStorage.cs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
using System.Text.Json;
|
||||
using Windows.Storage;
|
||||
|
||||
namespace ServerMonitorManager_Desktop;
|
||||
|
||||
public sealed record MetricSampleData(
|
||||
string ServerId,
|
||||
DateTimeOffset Timestamp,
|
||||
double CpuPercent,
|
||||
double MemoryPercent,
|
||||
double DiskPercent);
|
||||
|
||||
public sealed class MetricsHistoryStorage
|
||||
{
|
||||
private const string FileName = "metrics-history.json";
|
||||
private const int MaxSamplesPerServer = 240;
|
||||
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = false };
|
||||
|
||||
public async Task<List<MetricSampleData>> LoadAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var file = await ApplicationData.Current.LocalFolder.GetFileAsync(FileName);
|
||||
var json = await FileIO.ReadTextAsync(file);
|
||||
return JsonSerializer.Deserialize<List<MetricSampleData>>(json, JsonOptions) ?? [];
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SaveAsync(IEnumerable<MetricSampleData> samples)
|
||||
{
|
||||
var trimmed = samples
|
||||
.GroupBy(sample => sample.ServerId, StringComparer.Ordinal)
|
||||
.SelectMany(group => group.OrderByDescending(sample => sample.Timestamp).Take(MaxSamplesPerServer))
|
||||
.OrderBy(sample => sample.Timestamp)
|
||||
.ToList();
|
||||
var file = await ApplicationData.Current.LocalFolder.CreateFileAsync(
|
||||
FileName,
|
||||
CreationCollisionOption.ReplaceExisting);
|
||||
await FileIO.WriteTextAsync(file, JsonSerializer.Serialize(trimmed, JsonOptions));
|
||||
}
|
||||
}
|
||||
53
src/ServerMonitorManager.Desktop/Package.appxmanifest
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<Package
|
||||
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
|
||||
xmlns:mp="http://schemas.microsoft.com/appx/2014/phone/manifest"
|
||||
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
|
||||
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
|
||||
IgnorableNamespaces="uap rescap">
|
||||
|
||||
<Identity
|
||||
Name="81AD4B9B-7597-44AB-93A0-D5A695B2D35E"
|
||||
Publisher="CN=AppPublisher"
|
||||
Version="1.0.0.0" />
|
||||
|
||||
<mp:PhoneIdentity PhoneProductId="81AD4B9B-7597-44AB-93A0-D5A695B2D35E" PhonePublisherId="00000000-0000-0000-0000-000000000000"/>
|
||||
|
||||
<Properties>
|
||||
<DisplayName>Server Monitor Manager</DisplayName>
|
||||
<PublisherDisplayName>Ochenstarik UI</PublisherDisplayName>
|
||||
<Logo>Assets\StoreLogo.png</Logo>
|
||||
</Properties>
|
||||
|
||||
<Dependencies>
|
||||
<TargetDeviceFamily Name="Windows.Universal" MinVersion="10.0.17763.0" MaxVersionTested="10.0.26226.0" />
|
||||
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0" MaxVersionTested="10.0.26226.0" />
|
||||
</Dependencies>
|
||||
|
||||
<Resources>
|
||||
<Resource Language="x-generate"/>
|
||||
</Resources>
|
||||
|
||||
<Applications>
|
||||
<Application Id="App"
|
||||
Executable="$targetnametoken$.exe"
|
||||
EntryPoint="$targetentrypoint$">
|
||||
<uap:VisualElements
|
||||
DisplayName="Server Monitor Manager"
|
||||
Description="Лёгкий мониторинг и безопасное управление серверами"
|
||||
BackgroundColor="transparent"
|
||||
Square150x150Logo="Assets\Square150x150Logo.png"
|
||||
Square44x44Logo="Assets\Square44x44Logo.png">
|
||||
<uap:DefaultTile Wide310x150Logo="Assets\Wide310x150Logo.png" />
|
||||
<uap:SplashScreen Image="Assets\SplashScreen.png" />
|
||||
</uap:VisualElements>
|
||||
</Application>
|
||||
</Applications>
|
||||
|
||||
<Capabilities>
|
||||
<Capability Name="internetClient" />
|
||||
<Capability Name="privateNetworkClientServer" />
|
||||
<rescap:Capability Name="runFullTrust" />
|
||||
</Capabilities>
|
||||
</Package>
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"profiles": {
|
||||
"ServerMonitorManager.Desktop (Package)": {
|
||||
"commandName": "MsixPackage"
|
||||
},
|
||||
"ServerMonitorManager.Desktop (Unpackaged)": {
|
||||
"commandName": "Project"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net10.0-windows10.0.26100.0</TargetFramework>
|
||||
<TargetPlatformMinVersion>10.0.17763.0</TargetPlatformMinVersion>
|
||||
<RootNamespace>ServerMonitorManager_Desktop</RootNamespace>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<ApplicationIcon>Assets\AppIcon.ico</ApplicationIcon>
|
||||
<Platforms>x86;x64;ARM64</Platforms>
|
||||
<RuntimeIdentifier Condition="'$(RuntimeIdentifier)' == ''">win-$([System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture.ToString().ToLowerInvariant())</RuntimeIdentifier>
|
||||
<PublishProfile Condition="Exists('Properties\PublishProfiles\win-$(Platform).pubxml')">win-$(Platform).pubxml</PublishProfile>
|
||||
<UseWinUI>true</UseWinUI>
|
||||
<WinUISDKReferences>false</WinUISDKReferences>
|
||||
<EnableMsixTooling>true</EnableMsixTooling>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="Assets\SplashScreen.scale-200.png" />
|
||||
<Content Include="Assets\LockScreenLogo.scale-200.png" />
|
||||
<Content Include="Assets\Square150x150Logo.scale-200.png" />
|
||||
<Content Include="Assets\Square44x44Logo.scale-200.png" />
|
||||
<Content Include="Assets\Square44x44Logo.targetsize-24_altform-unplated.png" />
|
||||
<Content Include="Assets\Square44x44Logo.targetsize-48_altform-lightunplated.png" />
|
||||
<Content Include="Assets\StoreLogo.png" />
|
||||
<Content Include="Assets\AppIcon.ico" />
|
||||
<Content Include="Assets\AppIcon.png" />
|
||||
<Content Include="Assets\AppIconSmall.png" />
|
||||
<Content Include="Assets\Favicon.png" />
|
||||
<Content Include="Assets\Wide310x150Logo.scale-200.png" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Manifest Include="$(ApplicationManifest)" />
|
||||
</ItemGroup>
|
||||
|
||||
<!--
|
||||
Defining the "Msix" ProjectCapability here allows the Single-project MSIX Packaging
|
||||
Tools extension to be activated for this project even if the Windows App SDK Nuget
|
||||
package has not yet been restored.
|
||||
-->
|
||||
<ItemGroup Condition="'$(DisableMsixProjectCapabilityAddedByProject)'!='true' and '$(EnableMsixTooling)'=='true'">
|
||||
<ProjectCapability Include="Msix" />
|
||||
</ItemGroup>
|
||||
|
||||
<!--
|
||||
Microsoft.Windows.SDK.BuildTools.WinApp adds first-class support for
|
||||
`dotnet run` on packaged WinUI apps: it hooks the .NET CLI Run target to
|
||||
register a debug identity via the winapp CLI and launch the app with
|
||||
package identity (AUMID).
|
||||
Set <EnableWinAppRunSupport>false</EnableWinAppRunSupport> in a
|
||||
<PropertyGroup> above to opt out.
|
||||
-->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ServerMonitorManager.Core\ServerMonitorManager.Core.csproj" />
|
||||
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.28000.2270" />
|
||||
<PackageReference Include="Microsoft.WindowsAppSDK" Version="2.2.0" />
|
||||
<PackageReference Include="Microsoft.Windows.SDK.BuildTools.WinApp" Version="0.4.0" />
|
||||
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<!--
|
||||
Defining the "HasPackageAndPublishMenuAddedByProject" property here allows the Solution
|
||||
Explorer "Package and Publish" context menu entry to be enabled for this project even if
|
||||
the Windows App SDK Nuget package has not yet been restored.
|
||||
-->
|
||||
<PropertyGroup Condition="'$(DisableHasPackageAndPublishMenuAddedByProject)'!='true' and '$(EnableMsixTooling)'=='true'">
|
||||
<HasPackageAndPublishMenu>true</HasPackageAndPublishMenu>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Publish Properties -->
|
||||
<PropertyGroup>
|
||||
<PublishReadyToRun Condition="'$(Configuration)' == 'Debug'">False</PublishReadyToRun>
|
||||
<PublishReadyToRun Condition="'$(Configuration)' != 'Debug'">True</PublishReadyToRun>
|
||||
<PublishTrimmed Condition="'$(Configuration)' == 'Debug'">False</PublishTrimmed>
|
||||
<PublishTrimmed Condition="'$(Configuration)' != 'Debug'">True</PublishTrimmed>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
36
src/ServerMonitorManager.Desktop/ServerStorage.cs
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
using System.Text.Json;
|
||||
using Windows.Storage;
|
||||
|
||||
namespace ServerMonitorManager_Desktop;
|
||||
|
||||
public sealed class ServerStorage
|
||||
{
|
||||
private const string FileName = "servers.json";
|
||||
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
|
||||
|
||||
public async Task<IReadOnlyList<ServerProfileData>> LoadAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var file = await ApplicationData.Current.LocalFolder.GetFileAsync(FileName);
|
||||
var json = await FileIO.ReadTextAsync(file);
|
||||
return JsonSerializer.Deserialize<List<ServerProfileData>>(json, JsonOptions) ?? [];
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SaveAsync(IEnumerable<ServerProfileData> servers)
|
||||
{
|
||||
var file = await ApplicationData.Current.LocalFolder.CreateFileAsync(
|
||||
FileName,
|
||||
CreationCollisionOption.ReplaceExisting);
|
||||
await FileIO.WriteTextAsync(file, JsonSerializer.Serialize(servers, JsonOptions));
|
||||
}
|
||||
}
|
||||
54
src/ServerMonitorManager.Desktop/ServerViewModel.cs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
using System.ComponentModel;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace ServerMonitorManager_Desktop;
|
||||
|
||||
public sealed record ServerProfileData(
|
||||
string Id,
|
||||
string Name,
|
||||
string Host,
|
||||
int Port,
|
||||
string User,
|
||||
bool IsHub = false);
|
||||
|
||||
public sealed class ServerViewModel : INotifyPropertyChanged
|
||||
{
|
||||
private string _status = "Ожидает проверки";
|
||||
private string _cpuText = "—";
|
||||
private double _cpuPercent;
|
||||
private string _memoryText = "—";
|
||||
private string _diskText = "—";
|
||||
private string _latencyText = "—";
|
||||
private string _healthText = "—";
|
||||
private bool _isOnline;
|
||||
private bool _hasWarning;
|
||||
|
||||
public ServerViewModel(ServerProfileData profile) => Profile = profile;
|
||||
|
||||
public ServerProfileData Profile { get; }
|
||||
public string Name => Profile.Name;
|
||||
public string Endpoint => $"{Profile.User}@{Profile.Host}:{Profile.Port}";
|
||||
public bool IsHub => Profile.IsHub;
|
||||
public string Status { get => _status; set => Set(ref _status, value); }
|
||||
public string CpuText { get => _cpuText; set => Set(ref _cpuText, value); }
|
||||
public double CpuPercent { get => _cpuPercent; set => Set(ref _cpuPercent, value); }
|
||||
public string MemoryText { get => _memoryText; set => Set(ref _memoryText, value); }
|
||||
public string DiskText { get => _diskText; set => Set(ref _diskText, value); }
|
||||
public string LatencyText { get => _latencyText; set => Set(ref _latencyText, value); }
|
||||
public string HealthText { get => _healthText; set => Set(ref _healthText, value); }
|
||||
public bool IsOnline { get => _isOnline; set => Set(ref _isOnline, value); }
|
||||
public bool HasWarning { get => _hasWarning; set => Set(ref _hasWarning, value); }
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
private void Set<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
|
||||
{
|
||||
if (EqualityComparer<T>.Default.Equals(field, value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
field = value;
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
||||
}
|
||||
}
|
||||
308
src/ServerMonitorManager.Desktop/SshMonitorService.cs
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.RegularExpressions;
|
||||
using Windows.Storage;
|
||||
|
||||
namespace ServerMonitorManager_Desktop;
|
||||
|
||||
public sealed record ServerMetrics(
|
||||
string Hostname,
|
||||
double CpuPercent,
|
||||
long MemoryUsedKb,
|
||||
long MemoryTotalKb,
|
||||
long DiskUsedKb,
|
||||
long DiskTotalKb,
|
||||
long SwapUsedKb,
|
||||
long SwapTotalKb,
|
||||
long InodesUsed,
|
||||
long InodesTotal,
|
||||
long NetworkRxBytes,
|
||||
long NetworkTxBytes,
|
||||
string SshState,
|
||||
string WireGuardState,
|
||||
TimeSpan Uptime,
|
||||
TimeSpan Latency);
|
||||
|
||||
public sealed partial class SshMonitorService
|
||||
{
|
||||
private const string KeyFileName = "server-monitor-manager-ed25519";
|
||||
private const string ProtectedKeySuffix = ".dpapi";
|
||||
|
||||
public async Task<string> EnsureKeyPairAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var keyFolder = await ApplicationData.Current.LocalFolder.CreateFolderAsync(
|
||||
"ssh",
|
||||
CreationCollisionOption.OpenIfExists);
|
||||
var privateKeyPath = Path.Combine(keyFolder.Path, KeyFileName);
|
||||
var publicKeyPath = privateKeyPath + ".pub";
|
||||
var protectedKeyPath = privateKeyPath + ProtectedKeySuffix;
|
||||
|
||||
if (!File.Exists(publicKeyPath) || (!File.Exists(privateKeyPath) && !File.Exists(protectedKeyPath)))
|
||||
{
|
||||
File.Delete(protectedKeyPath);
|
||||
var arguments = new[]
|
||||
{
|
||||
"-q", "-t", "ed25519", "-a", "64", "-N", string.Empty,
|
||||
"-C", "server-monitor-manager", "-f", privateKeyPath
|
||||
};
|
||||
await RunProcessAsync(ResolveOpenSshTool("ssh-keygen.exe"), arguments, cancellationToken);
|
||||
}
|
||||
|
||||
if (File.Exists(privateKeyPath))
|
||||
{
|
||||
var privateKey = await File.ReadAllBytesAsync(privateKeyPath, cancellationToken);
|
||||
var protectedKey = ProtectedData.Protect(privateKey, null, DataProtectionScope.CurrentUser);
|
||||
await File.WriteAllBytesAsync(protectedKeyPath, protectedKey, cancellationToken);
|
||||
CryptographicOperations.ZeroMemory(privateKey);
|
||||
File.Delete(privateKeyPath);
|
||||
}
|
||||
|
||||
return (await File.ReadAllTextAsync(publicKeyPath, cancellationToken)).Trim();
|
||||
}
|
||||
|
||||
public async Task<ServerMetrics> QueryAsync(
|
||||
ServerProfileData profile,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var output = await RunRestrictedCommandAsync(profile, "metrics", cancellationToken);
|
||||
stopwatch.Stop();
|
||||
|
||||
var values = output
|
||||
.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(line => line.Split('=', 2))
|
||||
.Where(parts => parts.Length == 2)
|
||||
.ToDictionary(parts => parts[0], parts => parts[1], StringComparer.Ordinal);
|
||||
|
||||
var cpuCount = ReadLong(values, "CPU_COUNT", 1);
|
||||
var load1 = ReadDouble(values, "LOAD1");
|
||||
var cpuPercent = Math.Clamp(load1 / Math.Max(1, cpuCount) * 100, 0, 100);
|
||||
var memoryTotal = ReadLong(values, "MEM_TOTAL_KB");
|
||||
var memoryAvailable = ReadLong(values, "MEM_AVAILABLE_KB");
|
||||
var diskTotal = ReadLong(values, "DISK_TOTAL_KB");
|
||||
var diskAvailable = ReadLong(values, "DISK_AVAILABLE_KB");
|
||||
var swapTotal = ReadLong(values, "SWAP_TOTAL_KB");
|
||||
var swapFree = ReadLong(values, "SWAP_FREE_KB");
|
||||
var inodesTotal = ReadLong(values, "DISK_INODES_TOTAL");
|
||||
var inodesFree = ReadLong(values, "DISK_INODES_FREE");
|
||||
|
||||
return new ServerMetrics(
|
||||
values.GetValueOrDefault("HOSTNAME", profile.Name),
|
||||
cpuPercent,
|
||||
Math.Max(0, memoryTotal - memoryAvailable),
|
||||
memoryTotal,
|
||||
Math.Max(0, diskTotal - diskAvailable),
|
||||
diskTotal,
|
||||
Math.Max(0, swapTotal - swapFree),
|
||||
swapTotal,
|
||||
Math.Max(0, inodesTotal - inodesFree),
|
||||
inodesTotal,
|
||||
ReadLong(values, "NETWORK_RX_BYTES"),
|
||||
ReadLong(values, "NETWORK_TX_BYTES"),
|
||||
values.GetValueOrDefault("SYSTEMD_SSH", "unknown"),
|
||||
values.GetValueOrDefault("SYSTEMD_WIREGUARD", "unknown"),
|
||||
TimeSpan.FromSeconds(ReadLong(values, "UPTIME_SECONDS")),
|
||||
stopwatch.Elapsed);
|
||||
}
|
||||
|
||||
public async Task<string> RunRestrictedCommandAsync(
|
||||
ServerProfileData profile,
|
||||
string command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ValidateProfile(profile);
|
||||
if (!SafeRestrictedCommandRegex().IsMatch(command))
|
||||
{
|
||||
throw new InvalidOperationException("Некорректная команда управления Mesh.");
|
||||
}
|
||||
|
||||
await EnsureKeyPairAsync(cancellationToken);
|
||||
var localFolder = ApplicationData.Current.LocalFolder.Path;
|
||||
var privateKeyPath = await MaterializePrivateKeyAsync(cancellationToken);
|
||||
var knownHostsPath = Path.Combine(localFolder, "ssh", "known_hosts");
|
||||
var target = $"{profile.User}@{profile.Host}";
|
||||
var arguments = new[]
|
||||
{
|
||||
"-i", privateKeyPath,
|
||||
"-p", profile.Port.ToString(CultureInfo.InvariantCulture),
|
||||
"-o", "BatchMode=yes",
|
||||
"-o", "ConnectTimeout=8",
|
||||
"-o", "IdentitiesOnly=yes",
|
||||
"-o", "StrictHostKeyChecking=accept-new",
|
||||
"-o", $"UserKnownHostsFile={knownHostsPath}",
|
||||
target,
|
||||
command
|
||||
};
|
||||
try
|
||||
{
|
||||
return await RunProcessAsync(
|
||||
ResolveOpenSshTool("ssh.exe"),
|
||||
arguments,
|
||||
cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(privateKeyPath);
|
||||
}
|
||||
}
|
||||
|
||||
public void OpenInteractiveTerminal(ServerProfileData profile, string terminalUser)
|
||||
{
|
||||
ValidateProfile(profile);
|
||||
if (!SafeUserRegex().IsMatch(terminalUser))
|
||||
{
|
||||
throw new InvalidOperationException("Некорректное имя пользователя терминала.");
|
||||
}
|
||||
|
||||
var ssh = ResolveOpenSshTool("ssh.exe");
|
||||
var sshArguments = new[]
|
||||
{
|
||||
"-p", profile.Port.ToString(CultureInfo.InvariantCulture),
|
||||
$"{terminalUser}@{profile.Host}"
|
||||
};
|
||||
var windowsTerminal = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"Microsoft",
|
||||
"WindowsApps",
|
||||
"wt.exe");
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = File.Exists(windowsTerminal) ? windowsTerminal : ssh,
|
||||
UseShellExecute = true
|
||||
};
|
||||
if (File.Exists(windowsTerminal))
|
||||
{
|
||||
startInfo.ArgumentList.Add("new-tab");
|
||||
startInfo.ArgumentList.Add(ssh);
|
||||
}
|
||||
foreach (var argument in sshArguments)
|
||||
{
|
||||
startInfo.ArgumentList.Add(argument);
|
||||
}
|
||||
_ = Process.Start(startInfo)
|
||||
?? throw new InvalidOperationException("Не удалось открыть SSH-терминал.");
|
||||
}
|
||||
|
||||
private static async Task<string> MaterializePrivateKeyAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var localFolder = ApplicationData.Current.LocalFolder.Path;
|
||||
var protectedKeyPath = Path.Combine(localFolder, "ssh", KeyFileName + ProtectedKeySuffix);
|
||||
var protectedKey = await File.ReadAllBytesAsync(protectedKeyPath, cancellationToken);
|
||||
var privateKey = ProtectedData.Unprotect(protectedKey, null, DataProtectionScope.CurrentUser);
|
||||
var temporaryFile = await ApplicationData.Current.TemporaryFolder.CreateFileAsync(
|
||||
$"{KeyFileName}-{Guid.NewGuid():N}",
|
||||
CreationCollisionOption.FailIfExists);
|
||||
try
|
||||
{
|
||||
await File.WriteAllBytesAsync(temporaryFile.Path, privateKey, cancellationToken);
|
||||
return temporaryFile.Path;
|
||||
}
|
||||
catch
|
||||
{
|
||||
File.Delete(temporaryFile.Path);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(privateKey);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateProfile(ServerProfileData profile)
|
||||
{
|
||||
if (!SafeHostRegex().IsMatch(profile.Host))
|
||||
{
|
||||
throw new InvalidOperationException("Некорректный адрес сервера.");
|
||||
}
|
||||
|
||||
if (!SafeUserRegex().IsMatch(profile.User))
|
||||
{
|
||||
throw new InvalidOperationException("Некорректное имя SSH-пользователя.");
|
||||
}
|
||||
|
||||
if (profile.Port is < 1 or > 65535)
|
||||
{
|
||||
throw new InvalidOperationException("SSH-порт должен быть от 1 до 65535.");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<string> RunProcessAsync(
|
||||
string fileName,
|
||||
IEnumerable<string> arguments,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = fileName,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true
|
||||
};
|
||||
foreach (var argument in arguments)
|
||||
{
|
||||
startInfo.ArgumentList.Add(argument);
|
||||
}
|
||||
|
||||
using var process = Process.Start(startInfo)
|
||||
?? throw new InvalidOperationException($"Не удалось запустить {Path.GetFileName(fileName)}.");
|
||||
var outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken);
|
||||
var errorTask = process.StandardError.ReadToEndAsync(cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
await process.WaitForExitAsync(cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (!process.HasExited)
|
||||
{
|
||||
process.Kill(entireProcessTree: true);
|
||||
}
|
||||
throw;
|
||||
}
|
||||
|
||||
var output = await outputTask;
|
||||
var error = await errorTask;
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
string.IsNullOrWhiteSpace(error)
|
||||
? $"SSH завершился с кодом {process.ExitCode}."
|
||||
: error.Trim());
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
private static string ResolveOpenSshTool(string fileName)
|
||||
{
|
||||
var systemTool = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.System),
|
||||
"OpenSSH",
|
||||
fileName);
|
||||
return File.Exists(systemTool) ? systemTool : fileName;
|
||||
}
|
||||
|
||||
private static long ReadLong(IReadOnlyDictionary<string, string> values, string key, long fallback = 0)
|
||||
=> values.TryGetValue(key, out var value)
|
||||
&& long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
|
||||
? parsed
|
||||
: fallback;
|
||||
|
||||
private static double ReadDouble(IReadOnlyDictionary<string, string> values, string key)
|
||||
=> values.TryGetValue(key, out var value)
|
||||
&& double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed)
|
||||
? parsed
|
||||
: 0;
|
||||
|
||||
[GeneratedRegex("^[A-Za-z0-9._:-]{1,255}$", RegexOptions.CultureInvariant)]
|
||||
private static partial Regex SafeHostRegex();
|
||||
|
||||
[GeneratedRegex("^[a-z_][a-z0-9_-]{0,31}$", RegexOptions.CultureInvariant)]
|
||||
private static partial Regex SafeUserRegex();
|
||||
|
||||
[GeneratedRegex("^(metrics|mesh (nodes|links|status|connect [a-z0-9][a-z0-9-]{0,31} [a-z0-9][a-z0-9-]{0,31} (tcp|udp) [0-9]{1,5} [0-9]{1,6}|disconnect [a-z0-9][a-z0-9-]{0,31} [a-z0-9][a-z0-9-]{0,31} (tcp|udp) [0-9]{1,5}))$", RegexOptions.CultureInvariant)]
|
||||
private static partial Regex SafeRestrictedCommandRegex();
|
||||
}
|
||||
19
src/ServerMonitorManager.Desktop/app.manifest
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<assemblyIdentity version="1.0.0.0" name="ServerMonitorManager.Desktop.app"/>
|
||||
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<!-- The ID below informs the system that this application is compatible with OS features first introduced in Windows 10.
|
||||
It is necessary to support features in unpackaged applications, for example the custom titlebar implementation.
|
||||
For more info see https://docs.microsoft.com/windows/apps/windows-app-sdk/use-windows-app-sdk-run-time#declare-os-compatibility-in-your-application-manifest -->
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||
</application>
|
||||
</compatibility>
|
||||
|
||||
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<windowsSettings>
|
||||
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
|
||||
</windowsSettings>
|
||||
</application>
|
||||
</assembly>
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ServerMonitorManager.Control;
|
||||
using Xunit;
|
||||
|
||||
namespace ServerMonitorManager.Control.Tests;
|
||||
|
||||
public sealed class CertificateAuthorityTests : IDisposable
|
||||
{
|
||||
private readonly string _directory = Path.Combine(Path.GetTempPath(), $"smm-ca-tests-{Guid.NewGuid():N}");
|
||||
|
||||
[Fact]
|
||||
public void IssuedAgentCertificateChainsToControlAuthority()
|
||||
{
|
||||
Directory.CreateDirectory(_directory);
|
||||
var caPath = Path.Combine(_directory, "control-ca.pfx");
|
||||
using var caKey = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var caRequest = new CertificateRequest("CN=SMM Test CA", caKey, HashAlgorithmName.SHA256);
|
||||
caRequest.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true));
|
||||
caRequest.CertificateExtensions.Add(new X509KeyUsageExtension(
|
||||
X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign,
|
||||
true));
|
||||
using var ca = caRequest.CreateSelfSigned(
|
||||
DateTimeOffset.UtcNow.AddMinutes(-1),
|
||||
DateTimeOffset.UtcNow.AddYears(2));
|
||||
File.WriteAllBytes(caPath, ca.Export(X509ContentType.Pfx));
|
||||
|
||||
using var authority = new CertificateAuthority(Options.Create(new ControlOptions
|
||||
{
|
||||
DatabasePath = Path.Combine(_directory, "unused.db"),
|
||||
CertificateAuthorityPath = caPath
|
||||
}));
|
||||
using var agentKey = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var agentRequest = new CertificateRequest("CN=home", agentKey, HashAlgorithmName.SHA256);
|
||||
var issued = authority.IssueClientCertificate("home", agentRequest.CreateSigningRequestPem());
|
||||
using var agentCertificate = X509Certificate2.CreateFromPem(
|
||||
issued.CertificatePem,
|
||||
agentKey.ExportPkcs8PrivateKeyPem());
|
||||
using var chain = new X509Chain();
|
||||
chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
|
||||
chain.ChainPolicy.CustomTrustStore.Add(authority.PublicCertificate);
|
||||
chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
|
||||
|
||||
Assert.True(chain.Build(agentCertificate));
|
||||
Assert.Contains(
|
||||
agentCertificate.Extensions.OfType<X509EnhancedKeyUsageExtension>()
|
||||
.SelectMany(extension => extension.EnhancedKeyUsages.Cast<Oid>()),
|
||||
oid => oid.Value == "1.3.6.1.5.5.7.3.2");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_directory))
|
||||
{
|
||||
Directory.Delete(_directory, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
230
tests/ServerMonitorManager.Control.Tests/ControlStoreTests.cs
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using ServerMonitorManager.Control;
|
||||
using ServerMonitorManager.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace ServerMonitorManager.Control.Tests;
|
||||
|
||||
public sealed class ControlStoreTests : IAsyncDisposable
|
||||
{
|
||||
private readonly string _directory = Path.Combine(Path.GetTempPath(), $"smm-tests-{Guid.NewGuid():N}");
|
||||
|
||||
[Fact]
|
||||
public async Task EnrollmentTokenIsAtomicAndIdempotent()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var store = CreateStore();
|
||||
await store.InitializeAsync(cancellationToken);
|
||||
var token = await store.CreateEnrollmentTokenAsync("home", TimeSpan.FromMinutes(10), cancellationToken);
|
||||
var idempotencyKey = Guid.NewGuid().ToString();
|
||||
var request = new EnrollmentRequest("home", token, "unused-in-store-test", idempotencyKey);
|
||||
var issued = new IssuedCertificate("certificate", "ca", "AA11", DateTimeOffset.UtcNow.AddYears(1));
|
||||
|
||||
var first = await store.EnrollAsync(request, () => issued, cancellationToken);
|
||||
var retry = await store.EnrollAsync(request, () => throw new InvalidOperationException("must use cache"), cancellationToken);
|
||||
var replay = await store.EnrollAsync(
|
||||
request with { IdempotencyKey = Guid.NewGuid().ToString() },
|
||||
() => issued,
|
||||
cancellationToken);
|
||||
|
||||
Assert.NotNull(first);
|
||||
Assert.Equal(first, retry);
|
||||
Assert.Null(replay);
|
||||
Assert.True(await store.IsCertificateForNodeAsync("AA11", "home", cancellationToken));
|
||||
Assert.False(await store.IsCertificateForNodeAsync("AA11", "other", cancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HeartbeatRetryDoesNotDuplicateMetricSample()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var store = CreateStore();
|
||||
await store.InitializeAsync(cancellationToken);
|
||||
var token = await store.CreateEnrollmentTokenAsync("home", TimeSpan.FromMinutes(10), cancellationToken);
|
||||
var issued = new IssuedCertificate("certificate", "ca", "BB22", DateTimeOffset.UtcNow.AddYears(1));
|
||||
await store.EnrollAsync(
|
||||
new EnrollmentRequest("home", token, "csr", Guid.NewGuid().ToString()),
|
||||
() => issued,
|
||||
cancellationToken);
|
||||
var heartbeat = new AgentHeartbeat(
|
||||
"home", "test", DateTimeOffset.UtcNow, 0.5, 1, 2, 3, 4, 5, 6, 7,
|
||||
Guid.NewGuid().ToString());
|
||||
|
||||
var first = await store.RecordHeartbeatAsync(heartbeat, 30, cancellationToken);
|
||||
var retry = await store.RecordHeartbeatAsync(heartbeat, 30, cancellationToken);
|
||||
|
||||
Assert.Equal(first, retry);
|
||||
Assert.Equal(1, first.Sequence);
|
||||
await Assert.ThrowsAsync<IdempotencyConflictException>(() => store.RecordHeartbeatAsync(
|
||||
heartbeat with { LoadOne = 0.9 },
|
||||
30,
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeviceCertificateReceivesOperatorIdentity()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var store = CreateStore();
|
||||
await store.InitializeAsync(cancellationToken);
|
||||
var token = await store.CreateDeviceEnrollmentTokenAsync(
|
||||
"windows-pc", TimeSpan.FromMinutes(10), cancellationToken);
|
||||
var issued = new IssuedCertificate("certificate", "ca", "CC33", DateTimeOffset.UtcNow.AddYears(1));
|
||||
var request = new DeviceEnrollmentRequest(
|
||||
"windows-pc", token, "csr", Guid.NewGuid().ToString());
|
||||
|
||||
var first = await store.EnrollDeviceAsync(request, () => issued, cancellationToken);
|
||||
var retry = await store.EnrollDeviceAsync(
|
||||
request,
|
||||
() => throw new InvalidOperationException("must use cache"),
|
||||
cancellationToken);
|
||||
var identity = await store.ResolveIdentityAsync("CC33", cancellationToken);
|
||||
|
||||
Assert.NotNull(first);
|
||||
Assert.Equal(first, retry);
|
||||
Assert.Equal(new ControlIdentity("windows-pc", "Operator"), identity);
|
||||
Assert.False(await store.IsCertificateForNodeAsync("CC33", "home", cancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LinkDesiredStateIsPersistedBeforeActualStateChanges()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var store = CreateStore();
|
||||
await store.InitializeAsync(cancellationToken);
|
||||
await EnrollAgentAsync(store, "ai-agent", "DD44", cancellationToken);
|
||||
await EnrollAgentAsync(store, "home", "EE55", cancellationToken);
|
||||
var request = new LinkPolicyCreateRequest(
|
||||
"ai-agent", "home", "tcp", 22, 120, "development", Guid.NewGuid().ToString());
|
||||
|
||||
var connectingMutation = await store.CreateLinkMutationAsync(request, "windows-pc", cancellationToken);
|
||||
var connecting = connectingMutation.Link;
|
||||
var active = await store.SetLinkActualStateAsync(
|
||||
connecting.Id, "Active", null, "windows-pc", cancellationToken);
|
||||
var disconnectingMutation = await store.BeginDisableLinkMutationAsync(
|
||||
connecting.Id,
|
||||
new LinkPolicyDisableRequest(Guid.NewGuid().ToString()),
|
||||
"windows-pc",
|
||||
cancellationToken);
|
||||
var disconnecting = disconnectingMutation?.Link;
|
||||
var disabled = await store.SetLinkActualStateAsync(
|
||||
connecting.Id, "Disabled", null, "windows-pc", cancellationToken);
|
||||
var links = await store.ListLinksAsync(cancellationToken);
|
||||
|
||||
Assert.Equal("Active", connecting.DesiredState);
|
||||
Assert.Equal("Connecting", connecting.ActualState);
|
||||
Assert.Equal("Active", active?.ActualState);
|
||||
Assert.Equal("Disabled", disconnecting?.DesiredState);
|
||||
Assert.Equal("Disconnecting", disconnecting?.ActualState);
|
||||
Assert.Equal("Disabled", disabled?.ActualState);
|
||||
Assert.Single(links);
|
||||
Assert.Equal(disabled, links[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LinkServiceAppliesPersistedStatesAndPublishesEvents()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var store = CreateStore();
|
||||
await store.InitializeAsync(cancellationToken);
|
||||
await EnrollAgentAsync(store, "ai-agent", "FF66", cancellationToken);
|
||||
await EnrollAgentAsync(store, "home", "0011", cancellationToken);
|
||||
var broker = new ControlEventBroker();
|
||||
using var subscription = broker.Subscribe();
|
||||
var applier = new CheckingPolicyApplier(store);
|
||||
var service = new LinkService(store, applier, broker);
|
||||
var createRequest = new LinkPolicyCreateRequest(
|
||||
"ai-agent", "home", "tcp", 22, 30, "test", Guid.NewGuid().ToString());
|
||||
|
||||
var active = await service.CreateAsync(
|
||||
createRequest,
|
||||
"windows-pc",
|
||||
cancellationToken);
|
||||
var createReplay = await service.CreateAsync(createRequest, "windows-pc", cancellationToken);
|
||||
var disableRequest = new LinkPolicyDisableRequest(Guid.NewGuid().ToString());
|
||||
var disabled = await service.DisableAsync(
|
||||
active.Id,
|
||||
disableRequest,
|
||||
"windows-pc",
|
||||
cancellationToken);
|
||||
var disableReplay = await service.DisableAsync(
|
||||
active.Id, disableRequest, "windows-pc", cancellationToken);
|
||||
var eventTypes = new List<string>();
|
||||
while (subscription.Reader.TryRead(out var controlEvent))
|
||||
{
|
||||
eventTypes.Add(controlEvent.Type);
|
||||
}
|
||||
|
||||
Assert.Equal("Active", active.ActualState);
|
||||
Assert.Equal(active, createReplay);
|
||||
Assert.Equal("Disabled", disabled?.DesiredState);
|
||||
Assert.Equal("Disabled", disabled?.ActualState);
|
||||
Assert.Equal(disabled, disableReplay);
|
||||
Assert.Equal(1, applier.ConnectCalls);
|
||||
Assert.Equal(1, applier.DisconnectCalls);
|
||||
Assert.Equal(
|
||||
["link.connecting", "link.active", "link.disconnecting", "link.disabled"],
|
||||
eventTypes);
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
SqliteConnection.ClearAllPools();
|
||||
if (Directory.Exists(_directory))
|
||||
{
|
||||
Directory.Delete(_directory, recursive: true);
|
||||
}
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private ControlStore CreateStore()
|
||||
{
|
||||
Directory.CreateDirectory(_directory);
|
||||
return new ControlStore(Options.Create(new ControlOptions
|
||||
{
|
||||
DatabasePath = Path.Combine(_directory, "control.db"),
|
||||
CertificateAuthorityPath = Path.Combine(_directory, "unused.pfx")
|
||||
}));
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ServerMonitorManager.Control\ServerMonitorManager.Control.csproj" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
|
||||
<PackageReference Include="xunit.v3" Version="3.2.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||