diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..b24738d
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,14 @@
+.vs/
+.idea/
+.vscode/
+bin/
+obj/
+artifacts/
+TestResults/
+*.user
+*.suo
+*.pfx
+*.key
+*.pem
+*.token
+appsettings.Production.json
diff --git a/README.md b/README.md
index 73eec8a..7dfb308 100644
--- a/README.md
+++ b/README.md
@@ -1,29 +1,81 @@
-# Mobile Server Manager
+# Server Monitor Manager
-Мобильная система управления Linux-серверами: отчёты, безопасные команды, резервные копии и прямые SSH-сессии.
+Лёгкая система мониторинга и безопасного управления Linux-серверами с одного ПК. Первый клиент создаётся для Windows; после стабилизации протокола появятся клиенты для других настольных систем и мобильных устройств.
-## Структура
+## Основные задачи
-- `mobile/` — Flutter-клиент для Android и iOS;
-- `src/ServerManager.Api/` — ASP.NET Core 10 API, Identity, TOTP 2FA и SignalR;
-- `src/ServerManager.Agent/` — исходящий агент управляемого Linux-сервера;
-- `deploy/` — установщик и systemd-файлы агента;
-- `docs/` — архитектура и модель безопасности.
+- единая панель для нескольких личных и рабочих серверов;
+- CPU/load, память, корневой диск, задержка и uptime через ограниченный SSH forced-command;
+- SSH-терминал и сохранённые подключения без передачи приватных ключей серверу управления;
+- группы серверов, теги, избранное и сводные предупреждения;
+- явное включение и ручное отключение защищённых связей между серверами;
+- ограниченный доступ автоматизированных инструментов, например Hermes, к выбранному серверу разработки;
+- маленький Linux-агент без тяжёлой панели, Docker и отдельной базы данных на каждом узле.
-## Статус
+## Архитектура
-Проект находится на раннем этапе разработки. Первая версия API содержит базовую модель пользователей и серверов. Опасные команды, удалённое восстановление и SSH-терминал будут включаться только после реализации аудита, повторного подтверждения и проверки полномочий.
+```text
+Windows client
+ |
+ | HTTPS / WebSocket, mTLS
+ v
+Control node (может работать на одном из ваших серверов)
+ ^
+ | только исходящие соединения агентов
+ |
+Linux agents ---- управляемые WireGuard links ---- Linux agents
+```
-Сейчас форма мобильного входа является интерфейсным прототипом и намеренно не пропускает пользователя без API. Управление серверами в API требует включённой TOTP-2FA. Production-запуск заблокирован до подключения PostgreSQL и миграций; агент пока только собирает локальные метрики и не отправляет их без защищённой регистрации.
+Мониторинг и межсерверные связи разделены. Регистрация агента не создаёт сетевого туннеля и не выдаёт доступ к SSH. Каждая связь создаётся отдельно, имеет список разрешённых адресов и портов, может иметь срок действия и всегда поддерживает ручной `Disconnect`.
-## Требования для разработки
+Подробности: [архитектура](docs/architecture.md), [модель безопасности](docs/security-model.md), [план разработки](docs/roadmap.md) и [контракт Linux-установщика](docs/installer-contract.md).
-- .NET 10 SDK;
-- Flutter stable с Android SDK;
-- Android Studio или Android command-line tools;
-- для сборки iOS — macOS с Xcode;
-- PostgreSQL для production; SQLite используется только при локальной разработке.
+## Планируемая структура
-Секреты, токены агентов и приватные SSH-ключи не должны попадать в Git.
+```text
+src/
+ ServerMonitorManager.Desktop/ WinUI 3, Windows-first
+ ServerMonitorManager.Core/ общие модели и сценарии
+ ServerMonitorManager.Agent/ лёгкий Linux-агент
+ ServerMonitorManager.Control/ API, события, аудит и координация
+docs/
+deploy/
+tests/
+```
-Подробный результат проверки ПК и порядок подготовки среды находятся в [`docs/development-setup.md`](docs/development-setup.md).
+## Текущий статус
+
+Проект находится на стадии первого тестируемого Windows MVP. Репозиторий переименован из `mobile-server-manager`, потому что первой целевой платформой теперь является Windows. Клиент создаёт отдельный Ed25519-ключ, сохраняет профили локально и получает живые метрики серверов через системный OpenSSH.
+
+Установочный скрипт Linux-части хранится в репозитории [`ochenstarik-ui/lightweight-server`](https://github.com/ochenstarik-ui/lightweight-server) под именем `ochenstarik-server-monitor-manager.sh`. Его копия находится в [`deploy/`](deploy/). Скрипт создаёт отдельного пользователя `ochenstarik-monitor` и SSH forced-command: ключ мониторинга не получает shell, PTY, port forwarding или право выполнять произвольные команды.
+
+## Быстрый тест на трёх серверах
+
+1. Запустите Windows-клиент и нажмите `SSH-ключ` → `Копировать`.
+2. На каждом Ubuntu/Debian-сервере скачайте и запустите установщик:
+
+```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 install
+```
+
+3. Вставьте скопированный публичный ключ по запросу скрипта.
+4. В приложении нажмите `Добавить сервер`, укажите IP/домен, текущий SSH-порт и пользователя `ochenstarik-monitor`.
+5. Повторите для остальных серверов и нажмите `Обновить`.
+
+Приватный ключ хранится в локальном каталоге packaged-приложения Windows. Первый SSH-host key принимается в режиме `accept-new`, затем проверяется по отдельному `known_hosts` приложения.
+
+## Принципы безопасности
+
+- никаких общих root-паролей и приватных SSH-ключей в control node;
+- на первом SSH-MVP — отдельный ключ мониторинга и pinned `known_hosts`; enrollment tokens появятся вместе с постоянным агентом;
+- отдельная идентичность для пользователя, устройства, агента и автоматизации;
+- опасные действия требуют повторного подтверждения и попадают в неизменяемый аудит;
+- доступ Hermes выдаётся отдельному Unix-пользователю и SSH-ключу с минимальными правами;
+- отключение Link удаляет маршруты и peer-конфигурацию, а не только скрывает кнопку в интерфейсе.
+
+## Лицензия и секреты
+
+Лицензия будет выбрана до публикации первого исходного релиза. Токены регистрации, ключи WireGuard, SSH-ключи, сертификаты устройств и production-конфигурация не должны попадать в Git.
diff --git a/deploy/ochenstarik-server-monitor-manager.sh b/deploy/ochenstarik-server-monitor-manager.sh
new file mode 100644
index 0000000..acd8289
--- /dev/null
+++ b/deploy/ochenstarik-server-monitor-manager.sh
@@ -0,0 +1,162 @@
+#!/usr/bin/env bash
+set -Eeuo pipefail
+
+readonly APP_NAME="ochenstarik-server-monitor-manager"
+readonly MONITOR_USER="ochenstarik-monitor"
+readonly MONITOR_HOME="/var/lib/${APP_NAME}"
+readonly MONITOR_COMMAND="/usr/local/libexec/ochenstarik-server-monitor"
+readonly AUTHORIZED_KEYS="${MONITOR_HOME}/.ssh/authorized_keys"
+
+log() { printf '[+] %s\n' "$*"; }
+warn() { printf '[!] %s\n' "$*" >&2; }
+die() { printf '[x] %s\n' "$*" >&2; exit 1; }
+
+require_root() {
+ [[ "$EUID" -eq 0 ]] || die "Запустите скрипт через sudo"
+}
+
+detect_system() {
+ [[ -r /etc/os-release ]] || die "Не найден /etc/os-release"
+ # shellcheck disable=SC1091
+ . /etc/os-release
+ case "${ID:-}" in
+ ubuntu|debian) ;;
+ *) die "Поддерживаются Ubuntu и Debian; обнаружено: ${ID:-unknown}" ;;
+ esac
+ command -v systemctl >/dev/null 2>&1 || die "Требуется systemd"
+}
+
+read_public_key() {
+ printf '\nВ Windows-приложении нажмите «SSH-ключ» → «Копировать».\n'
+ read -rp 'Вставьте публичный ключ: ' PUBLIC_KEY
+ PUBLIC_KEY="${PUBLIC_KEY//$'\r'/}"
+ [[ "$PUBLIC_KEY" =~ ^ssh-ed25519[[:space:]]+[A-Za-z0-9+/]+={0,3}([[:space:]].*)?$ ]] \
+ || die "Ожидается публичный ключ формата ssh-ed25519 AAAA..."
+}
+
+install_dependencies() {
+ apt-get update
+ DEBIAN_FRONTEND=noninteractive apt-get install -y \
+ ca-certificates openssh-server openssh-client coreutils gawk
+ systemctl enable --now ssh
+}
+
+verify_public_key() {
+ local key_file
+ key_file="$(mktemp)"
+ trap 'rm -f -- "${key_file:-}"' RETURN
+ printf '%s\n' "$PUBLIC_KEY" > "$key_file"
+ ssh-keygen -l -f "$key_file" >/dev/null \
+ || die "ssh-keygen отклонил публичный ключ"
+ trap - RETURN
+ rm -f -- "$key_file"
+}
+
+create_monitor_command() {
+ install -d -m 0755 -o root -g root "$(dirname "$MONITOR_COMMAND")"
+ [[ ! -L "$MONITOR_COMMAND" ]] \
+ || die "Отказ от записи через символическую ссылку: $MONITOR_COMMAND"
+ cat > "$MONITOR_COMMAND" <<'EOF'
+#!/usr/bin/env bash
+set -Eeuo pipefail
+export LC_ALL=C
+
+read_mem_value() {
+ awk -v key="$1" '$1 == key ":" { print $2; exit }' /proc/meminfo
+}
+
+printf 'PROTOCOL=1\n'
+printf 'HOSTNAME=%s\n' "$(hostname)"
+printf 'UPTIME_SECONDS=%s\n' "$(cut -d. -f1 /proc/uptime)"
+printf 'LOAD1=%s\n' "$(cut -d' ' -f1 /proc/loadavg)"
+printf 'CPU_COUNT=%s\n' "$(getconf _NPROCESSORS_ONLN)"
+printf 'MEM_TOTAL_KB=%s\n' "$(read_mem_value MemTotal)"
+printf 'MEM_AVAILABLE_KB=%s\n' "$(read_mem_value MemAvailable)"
+df -Pk / | awk 'NR == 2 {
+ printf "DISK_TOTAL_KB=%s\nDISK_AVAILABLE_KB=%s\n", $2, $4
+}'
+printf 'KERNEL=%s\n' "$(uname -r)"
+EOF
+ chown root:root "$MONITOR_COMMAND"
+ chmod 0755 "$MONITOR_COMMAND"
+}
+
+create_monitor_user() {
+ if ! getent passwd "$MONITOR_USER" >/dev/null; then
+ useradd \
+ --system \
+ --create-home \
+ --home-dir "$MONITOR_HOME" \
+ --shell /bin/bash \
+ "$MONITOR_USER"
+ fi
+ usermod --home "$MONITOR_HOME" --shell /bin/bash "$MONITOR_USER"
+ passwd -l "$MONITOR_USER" >/dev/null 2>&1 || true
+
+ install -d -m 0750 -o "$MONITOR_USER" -g "$MONITOR_USER" "$MONITOR_HOME"
+ install -d -m 0700 -o "$MONITOR_USER" -g "$MONITOR_USER" "${MONITOR_HOME}/.ssh"
+ [[ ! -L "$AUTHORIZED_KEYS" ]] \
+ || die "Отказ от записи через символическую ссылку: $AUTHORIZED_KEYS"
+ printf 'restrict,command="%s" %s\n' "$MONITOR_COMMAND" "$PUBLIC_KEY" > "$AUTHORIZED_KEYS"
+ chown "$MONITOR_USER:$MONITOR_USER" "$AUTHORIZED_KEYS"
+ chmod 0600 "$AUTHORIZED_KEYS"
+}
+
+verify_sshd() {
+ sshd -t
+ sshd -T | grep -qi '^pubkeyauthentication yes$' \
+ || die "В sshd отключена аутентификация по публичному ключу"
+}
+
+show_status() {
+ local ssh_port="unknown"
+ if command -v sshd >/dev/null 2>&1; then
+ ssh_port="$(sshd -T 2>/dev/null | awk '$1 == "port" { print $2; exit }')"
+ fi
+ printf 'Пользователь: %s\n' "$MONITOR_USER"
+ printf 'SSH-порт: %s\n' "${ssh_port:-unknown}"
+ printf 'Команда: %s\n' "$MONITOR_COMMAND"
+ if [[ -x "$MONITOR_COMMAND" ]]; then
+ runuser -u "$MONITOR_USER" -- "$MONITOR_COMMAND"
+ else
+ warn "Серверная часть ещё не установлена"
+ fi
+}
+
+install_server_part() {
+ read_public_key
+ install_dependencies
+ verify_public_key
+ create_monitor_command
+ create_monitor_user
+ verify_sshd
+ systemctl reload ssh
+ log "Серверная часть установлена"
+ log "Входящий порт не изменялся, новые правила UFW не создавались"
+ show_status
+}
+
+uninstall_server_part() {
+ local answer
+ read -rp 'Удалить пользователя мониторинга и forced-command? [y/N]: ' answer
+ [[ "$answer" =~ ^[Yy]$ ]] || { log "Отменено"; return 0; }
+ rm -f -- "$MONITOR_COMMAND"
+ if getent passwd "$MONITOR_USER" >/dev/null; then
+ userdel --remove "$MONITOR_USER" 2>/dev/null || userdel "$MONITOR_USER"
+ fi
+ log "Серверная часть удалена"
+}
+
+main() {
+ local action="${1:-install}"
+ require_root
+ detect_system
+ case "$action" in
+ install) install_server_part ;;
+ status) show_status ;;
+ uninstall) uninstall_server_part ;;
+ *) die "Использование: $0 [install|status|uninstall]" ;;
+ esac
+}
+
+main "$@"
diff --git a/docs/architecture.md b/docs/architecture.md
new file mode 100644
index 0000000..41035a2
--- /dev/null
+++ b/docs/architecture.md
@@ -0,0 +1,85 @@
+# Архитектура Server Monitor Manager
+
+## 1. Компоненты
+
+Для первого тестирования используется переходный режим **SSH pull**: Windows-клиент вызывает на сервере ограниченный forced-command и получает snapshot. Постоянный агент и control node ниже описывают следующий этап после проверки UX на трёх серверах.
+
+### Desktop client
+
+Windows-first приложение на WinUI 3. Оно показывает состояние всех серверов, графики, события, терминалы и связи. Клиент хранит пользовательские секреты в Windows Credential Locker/DPAPI и не передаёт приватные SSH-ключи control node.
+
+### Control node
+
+Небольшой координационный сервис, который можно установить на одном из своих серверов. Он принимает исходящие mTLS-соединения агентов, хранит инвентарь, короткую историю метрик, правила оповещений, команды и аудит. Для одного пользователя достаточно SQLite; внешний PostgreSQL не является требованием MVP.
+
+Control node не становится обязательным маршрутизатором трафика между серверами. При возможности соединение идёт напрямую. Relay добавляется позже как явно включаемый режим для узлов за NAT.
+
+### Linux agent
+
+Один статически собранный бинарный файл. Агент читает `/proc`, `/sys` и разрешённые systemd-состояния, агрегирует метрики локально и отправляет их наружу. По умолчанию он запускается непривилегированным пользователем и не открывает входящих портов.
+
+Привилегированные операции выносятся в маленький отдельный helper с белым списком действий. Агент мониторинга не должен постоянно иметь `root` или `CAP_NET_ADMIN`.
+
+## 2. Потоки данных
+
+### Метрики
+
+Агент отправляет компактный snapshot каждые 5–15 секунд. Control node сохраняет сырые точки недолго, затем выполняет downsampling. При потере сети агент держит ограниченный дисковый буфер и не заполняет диск.
+
+Минимальный snapshot:
+
+- load average и использование CPU;
+- использованная/доступная память и swap;
+- заполнение и inode выбранных файловых систем;
+- сетевые счётчики интерфейсов;
+- uptime, температура при наличии датчиков;
+- состояние выбранных systemd units;
+- только метаданные процессов, разрешённые политикой.
+
+### Команды и терминал
+
+Произвольная shell-команда не является обычной командой агента. Для безопасных операций используются типизированные действия: перезапуск разрешённой службы, чтение журнала с ограничением, получение списка процессов. Полный терминал создаётся как отдельная SSH-сессия от desktop client.
+
+### Links между серверами
+
+Link — отдельный ресурс, а не свойство группы серверов:
+
+```text
+Draft -> Connecting -> Active -> Disconnecting -> Disabled
+ \-> Failed
+Active -> Expired -> Disabled
+```
+
+Каждый Link содержит:
+
+- source и destination;
+- разрешённые IP/CIDR и TCP/UDP-порты;
+- режим `manual` или TTL;
+- WireGuard peer identities;
+- владельца, причину и запись аудита;
+- фактическое состояние обоих узлов.
+
+Control node передаёт каждому узлу только его часть конфигурации. Приватный WireGuard-ключ никогда не покидает узел. `Disconnect` удаляет peer и маршруты с обеих сторон; при недоступности одного узла команда остаётся обязательной и применяется при его следующем подключении.
+
+## 3. Сценарий Hermes
+
+1. На домашнем сервере создаётся отдельный пользователь, например `hermes-dev`, без root-доступа.
+2. Рабочие каталоги и разрешённые команды ограничиваются Unix-правами, группами, контейнером или sandbox-профилем.
+3. В Server Monitor Manager создаётся Link от сервера с Hermes к домашнему серверу только для адреса домашнего узла и SSH-порта.
+4. Hermes использует собственный SSH-ключ или короткоживущий SSH-сертификат.
+5. Пользователь нажимает `Connect`; после разработки — `Disconnect`.
+6. Отключение Link разрывает сетевую достижимость, а отзыв SSH-сертификата/ключа остаётся вторым независимым барьером.
+
+## 4. Режимы развёртывания
+
+- **Single owner:** один control node, один пользователь, SQLite. Основной MVP.
+- **Family/team:** несколько пользователей, роли и подтверждения. После MVP.
+- **Direct local:** desktop подключается к агентам внутри одной сети без публичного control node. Возможен позже, но использует тот же протокол идентичности.
+
+## 5. Целевые ограничения MVP
+
+- агент: один бинарник, idle RAM до 50 МБ, CPU в простое менее 1%;
+- control node: запуск без Kubernetes и обязательного Docker;
+- отсутствие открытого agent API в интернет;
+- работа при 50–100 серверах на одном небольшом control node;
+- деградация без потери управления: графики могут иметь пробелы, но команды не выполняются повторно без idempotency key.
diff --git a/docs/installer-contract.md b/docs/installer-contract.md
new file mode 100644
index 0000000..acdd521
--- /dev/null
+++ b/docs/installer-contract.md
@@ -0,0 +1,39 @@
+# Контракт Linux-установщика
+
+Файл `ochenstarik-server-monitor-manager.sh` находится в `ochenstarik-ui/lightweight-server`; синхронная копия хранится в `deploy/` этого репозитория.
+
+## Первый тестовый этап: SSH pull
+
+До появления постоянного агента Windows-клиент получает метрики через OpenSSH. Установщик:
+
+- поддерживает Ubuntu/Debian с systemd;
+- запрашивает публичный ключ `ssh-ed25519`, созданный Windows-клиентом;
+- проверяет синтаксис ключа через `ssh-keygen`;
+- устанавливает и включает OpenSSH Server, не меняя текущий SSH-порт;
+- создаёт отдельного системного пользователя `ochenstarik-monitor` без пароля;
+- устанавливает root-owned metrics command;
+- записывает ключ как `restrict,command="..."`;
+- не создаёт UFW-правил и не открывает дополнительных портов;
+- поддерживает `install`, `status` и `uninstall`;
+- допускает безопасный повторный запуск для замены ключа мониторинга.
+
+Forced-command отдаёт только строки `KEY=VALUE`:
+
+```text
+PROTOCOL=1
+HOSTNAME=server-name
+UPTIME_SECONDS=12345
+LOAD1=0.42
+CPU_COUNT=4
+MEM_TOTAL_KB=...
+MEM_AVAILABLE_KB=...
+DISK_TOTAL_KB=...
+DISK_AVAILABLE_KB=...
+KERNEL=...
+```
+
+Ключ мониторинга не должен позволять shell, PTY, agent forwarding, TCP forwarding или выполнение переданной клиентом команды. Полноценный SSH-терминал использует отдельную пользовательскую identity и не входит в этот ключ.
+
+## Будущий этап: постоянный агент
+
+После проверки UX на нескольких серверах SSH pull будет дополнен статическим агентом для потоковых метрик, systemd events, короткого локального буфера и управляемых WireGuard Links. Агент будет регистрироваться одноразовым token и работать по исходящему mTLS-соединению без входящего API.
diff --git a/docs/roadmap.md b/docs/roadmap.md
new file mode 100644
index 0000000..a30900b
--- /dev/null
+++ b/docs/roadmap.md
@@ -0,0 +1,52 @@
+# План разработки
+
+## Этап 0 — фундамент
+
+- [x] переименовать проект в Server Monitor Manager;
+- [x] разделить мониторинг, терминал и Links в архитектуре;
+- [x] определить безопасный контракт установки агента;
+- [ ] выбрать лицензию;
+- [ ] настроить CI, форматирование, тесты и релизные checksum.
+
+## Этап 1 — Windows MVP
+
+- [x] установить .NET SDK и официальный WinUI 3 toolchain;
+- [x] создать packaged WinUI 3 приложение с воспроизводимым CLI-запуском через WinApp;
+- [x] реализовать shell: Overview, Servers, Links, Sessions, Settings;
+- [x] добавить локальные demo-данные и адаптивный dashboard;
+- [x] генерировать отдельный Ed25519 SSH-ключ и хранить его в локальном каталоге packaged-приложения;
+- [x] сохранять профили серверов локально без паролей;
+- [x] собрать и реально запустить x64-приложение.
+
+## Этап 2 — мониторинг одного сервера
+
+- [x] первый бездемонный Linux endpoint через SSH forced-command;
+- [ ] статический постоянный Linux agent для amd64/arm64;
+- [ ] control node с SQLite, mTLS enrollment и WebSocket событиями;
+- [ ] CPU, RAM, disk, network, uptime и systemd units;
+- [ ] ограниченный локальный буфер и downsampling;
+- [ ] installer, update, rollback и uninstall;
+- [ ] предупреждения о диске, памяти и недоступности.
+
+## Этап 3 — несколько серверов и терминал
+
+- [ ] группы, теги, поиск и сводные статусы;
+- [ ] прямой SSH из Windows-клиента;
+- [ ] known_hosts pinning и отдельные profiles;
+- [ ] типизированные безопасные действия с аудитом;
+- [ ] экспорт диагностики без секретов.
+
+## Этап 4 — Links и Hermes
+
+- [ ] WireGuard peer helper с минимальными привилегиями;
+- [ ] policies по CIDR, протоколу, порту и TTL;
+- [ ] connect/disconnect с подтверждением обоих узлов;
+- [ ] обязательное отключение после reconnect;
+- [ ] отдельная automation identity для Hermes;
+- [ ] интеграционные тесты kill switch и частичных отказов.
+
+## Этап 5 — другие платформы
+
+- [ ] macOS и Linux desktop clients после стабилизации Core/API;
+- [ ] Android/iOS как companion-клиенты;
+- [ ] push-уведомления без передачи административных секретов стороннему push-провайдеру.
diff --git a/docs/security-model.md b/docs/security-model.md
new file mode 100644
index 0000000..bc6039a
--- /dev/null
+++ b/docs/security-model.md
@@ -0,0 +1,56 @@
+# Модель безопасности
+
+## Защищаемые данные
+
+- учётные записи и сессии пользователей;
+- device, agent, SSH и WireGuard identities;
+- команды, терминальные сессии и аудит;
+- topology серверов, адреса и метрики;
+- права автоматизаций, включая Hermes.
+
+## Границы доверия
+
+Desktop client, control node и каждый agent считаются отдельными субъектами. Компрометация одного агента не должна выдавать ключи других узлов или право создавать новые Links. Control node координирует публичные параметры и политики, но не хранит приватные WireGuard/SSH-ключи узлов.
+
+В первом SSH-MVP control node отсутствует. Windows-клиент генерирует отдельный Ed25519-ключ, а сервер привязывает его к `restrict,command`. Этот ключ предназначен только для чтения ограниченного snapshot метрик и не используется для интерактивного терминала или Hermes.
+
+## Регистрация агента
+
+1. Пользователь создаёт одноразовый enrollment token со сроком жизни не более 10 минут.
+2. Агент генерирует ключевую пару локально.
+3. По TLS агент предъявляет token и публичный ключ.
+4. Control node выдаёт agent certificate и помечает token использованным.
+5. Последующие соединения требуют mTLS. Повторное использование token отклоняется.
+
+## Авторизация
+
+- read-only мониторинг отделён от управления;
+- команды проверяются по типу ресурса и действию;
+- полный терминал требует свежего подтверждения пользователя;
+- создание Link требует точного набора маршрутов/портов, пустой набор не означает `allow all`;
+- automation identity не наследует интерактивные права владельца;
+- команды имеют срок действия, nonce/idempotency key и защиту от replay.
+
+## Аудит
+
+Записываются входы, регистрация устройств, изменение прав, команды, начало и завершение терминала, создание/подключение/отключение Link. Секреты и полный ввод терминала по умолчанию не журналируются. Аудит экспортируется во внешний append-only storage; локальный администратор control node всё равно считается способным изменить локальную БД.
+
+## Kill switch
+
+Отключение Link должно:
+
+1. перевести желаемое состояние в `Disabled`;
+2. удалить peer и маршруты на доступных узлах;
+3. заблокировать повторное автоматическое включение;
+4. сохранить обязательную команду для временно недоступного узла;
+5. показать пользователю разницу между желаемым и фактическим состоянием.
+
+Кнопка не должна сообщать «отключено», пока оба узла не подтвердили применение или интерфейс явно не показывает частичное отключение.
+
+## Не входит в первый MVP
+
+- выполнение произвольных root-команд из веб/API;
+- хранение пользовательских приватных SSH-ключей на control node;
+- автоматическое соединение всех серверов группы в одну flat network;
+- публичный входящий порт агента;
+- собственная криптография вместо TLS, SSH и WireGuard.
diff --git a/src/ServerMonitorManager.Desktop/.gitignore b/src/ServerMonitorManager.Desktop/.gitignore
new file mode 100644
index 0000000..dd39356
--- /dev/null
+++ b/src/ServerMonitorManager.Desktop/.gitignore
@@ -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
diff --git a/src/ServerMonitorManager.Desktop/App.xaml b/src/ServerMonitorManager.Desktop/App.xaml
new file mode 100644
index 0000000..578439c
--- /dev/null
+++ b/src/ServerMonitorManager.Desktop/App.xaml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ServerMonitorManager.Desktop/App.xaml.cs b/src/ServerMonitorManager.Desktop/App.xaml.cs
new file mode 100644
index 0000000..ad15c41
--- /dev/null
+++ b/src/ServerMonitorManager.Desktop/App.xaml.cs
@@ -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;
+
+///
+/// Provides application-specific behavior to supplement the default Application class.
+///
+public partial class App : Application
+{
+ private Window? _window;
+
+ ///
+ /// 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().
+ ///
+ public App()
+ {
+ InitializeComponent();
+ }
+
+ ///
+ /// Invoked when the application is launched.
+ ///
+ /// Details about the launch request and process.
+ protected override void OnLaunched(Microsoft.UI.Xaml.LaunchActivatedEventArgs args)
+ {
+ _window = new MainWindow();
+ _window.Activate();
+ }
+}
diff --git a/src/ServerMonitorManager.Desktop/Assets/AppIcon.ico b/src/ServerMonitorManager.Desktop/Assets/AppIcon.ico
new file mode 100644
index 0000000..235dad2
Binary files /dev/null and b/src/ServerMonitorManager.Desktop/Assets/AppIcon.ico differ
diff --git a/src/ServerMonitorManager.Desktop/Assets/LockScreenLogo.scale-200.png b/src/ServerMonitorManager.Desktop/Assets/LockScreenLogo.scale-200.png
new file mode 100644
index 0000000..33f889e
Binary files /dev/null and b/src/ServerMonitorManager.Desktop/Assets/LockScreenLogo.scale-200.png differ
diff --git a/src/ServerMonitorManager.Desktop/Assets/SplashScreen.scale-200.png b/src/ServerMonitorManager.Desktop/Assets/SplashScreen.scale-200.png
new file mode 100644
index 0000000..802c79d
Binary files /dev/null and b/src/ServerMonitorManager.Desktop/Assets/SplashScreen.scale-200.png differ
diff --git a/src/ServerMonitorManager.Desktop/Assets/Square150x150Logo.scale-200.png b/src/ServerMonitorManager.Desktop/Assets/Square150x150Logo.scale-200.png
new file mode 100644
index 0000000..ddba42a
Binary files /dev/null and b/src/ServerMonitorManager.Desktop/Assets/Square150x150Logo.scale-200.png differ
diff --git a/src/ServerMonitorManager.Desktop/Assets/Square44x44Logo.scale-200.png b/src/ServerMonitorManager.Desktop/Assets/Square44x44Logo.scale-200.png
new file mode 100644
index 0000000..9327dd7
Binary files /dev/null and b/src/ServerMonitorManager.Desktop/Assets/Square44x44Logo.scale-200.png differ
diff --git a/src/ServerMonitorManager.Desktop/Assets/Square44x44Logo.targetsize-24_altform-unplated.png b/src/ServerMonitorManager.Desktop/Assets/Square44x44Logo.targetsize-24_altform-unplated.png
new file mode 100644
index 0000000..e51416a
Binary files /dev/null and b/src/ServerMonitorManager.Desktop/Assets/Square44x44Logo.targetsize-24_altform-unplated.png differ
diff --git a/src/ServerMonitorManager.Desktop/Assets/Square44x44Logo.targetsize-48_altform-lightunplated.png b/src/ServerMonitorManager.Desktop/Assets/Square44x44Logo.targetsize-48_altform-lightunplated.png
new file mode 100644
index 0000000..bf063eb
Binary files /dev/null and b/src/ServerMonitorManager.Desktop/Assets/Square44x44Logo.targetsize-48_altform-lightunplated.png differ
diff --git a/src/ServerMonitorManager.Desktop/Assets/StoreLogo.png b/src/ServerMonitorManager.Desktop/Assets/StoreLogo.png
new file mode 100644
index 0000000..be865f0
Binary files /dev/null and b/src/ServerMonitorManager.Desktop/Assets/StoreLogo.png differ
diff --git a/src/ServerMonitorManager.Desktop/Assets/Wide310x150Logo.scale-200.png b/src/ServerMonitorManager.Desktop/Assets/Wide310x150Logo.scale-200.png
new file mode 100644
index 0000000..314d297
Binary files /dev/null and b/src/ServerMonitorManager.Desktop/Assets/Wide310x150Logo.scale-200.png differ
diff --git a/src/ServerMonitorManager.Desktop/MainPage.xaml b/src/ServerMonitorManager.Desktop/MainPage.xaml
new file mode 100644
index 0000000..55b3922
--- /dev/null
+++ b/src/ServerMonitorManager.Desktop/MainPage.xaml
@@ -0,0 +1,293 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ServerMonitorManager.Desktop/MainPage.xaml.cs b/src/ServerMonitorManager.Desktop/MainPage.xaml.cs
new file mode 100644
index 0000000..198fed7
--- /dev/null
+++ b/src/ServerMonitorManager.Desktop/MainPage.xaml.cs
@@ -0,0 +1,291 @@
+using System.Collections.ObjectModel;
+using System.Globalization;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Automation;
+using Microsoft.UI.Xaml.Controls;
+using Windows.ApplicationModel.DataTransfer;
+
+namespace ServerMonitorManager_Desktop;
+
+public sealed partial class MainPage : Page
+{
+ private readonly ServerStorage _storage = new();
+ private readonly SshMonitorService _ssh = new();
+ private bool _loaded;
+
+ public MainPage()
+ {
+ InitializeComponent();
+ ManualLinkToggle.Toggled += ManualLinkToggle_Toggled;
+ Loaded += MainPage_Loaded;
+ MainNavigation.SelectedItem = MainNavigation.MenuItems[0];
+ }
+
+ public ObservableCollection Servers { get; } = [];
+
+ private async void MainPage_Loaded(object sender, RoutedEventArgs e)
+ {
+ if (_loaded)
+ {
+ return;
+ }
+
+ _loaded = true;
+ foreach (var profile in await _storage.LoadAsync())
+ {
+ Servers.Add(new ServerViewModel(profile));
+ }
+ UpdateEmptyState();
+
+ if (Servers.Count > 0)
+ {
+ await RefreshAllAsync();
+ }
+ }
+
+ 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 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 validationText = new TextBlock { TextWrapping = TextWrapping.Wrap };
+ var content = new StackPanel
+ {
+ Spacing = 12,
+ MinWidth = 420,
+ Children = { nameBox, hostBox, portBox, userBox, 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;
+ }
+
+ var profile = new ServerProfileData(
+ Guid.NewGuid().ToString("N"),
+ nameBox.Text.Trim(),
+ hostBox.Text.Trim(),
+ checked((int)portBox.Value),
+ userBox.Text.Trim());
+ var server = new ServerViewModel(profile);
+ Servers.Add(server);
+ await SaveProfilesAsync();
+ UpdateEmptyState();
+ await RefreshServerAsync(server);
+ }
+
+ private async void RefreshButton_Click(object sender, RoutedEventArgs e)
+ => await RefreshAllAsync();
+
+ private async Task RefreshAllAsync()
+ {
+ if (Servers.Count == 0)
+ {
+ ShowInfo("Серверы не добавлены", "Сначала создайте SSH-ключ и установите его на сервере.", InfoBarSeverity.Informational);
+ return;
+ }
+
+ await Task.WhenAll(Servers.Select(RefreshServerAsync));
+ var online = Servers.Count(server => server.IsOnline);
+ var warnings = Servers.Count - online;
+ 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 ? "Нет предупреждений" : "Проверьте SSH и ключ";
+ HeaderStatusText.Text = $"SSH monitoring · {Servers.Count} сервер(а) · обновлено {DateTime.Now:HH:mm:ss}";
+ }
+
+ 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";
+ server.LatencyText = $"{metrics.Latency.TotalMilliseconds:F0} ms";
+ server.Status = $"Онлайн · uptime {FormatUptime(metrics.Uptime)}";
+ server.IsOnline = true;
+ }
+ catch (Exception exception)
+ {
+ server.Status = CompactError(exception);
+ server.CpuText = "—";
+ server.CpuPercent = 0;
+ server.MemoryText = "—";
+ server.DiskText = "—";
+ server.LatencyText = "offline";
+ server.IsOnline = false;
+ }
+ }
+
+ 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 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 void ManualLinkToggle_Toggled(object sender, RoutedEventArgs e)
+ => ApplyLinkState(ManualLinkToggle.IsOn);
+
+ private void DisconnectButton_Click(object sender, RoutedEventArgs e)
+ => ManualLinkToggle.IsOn = false;
+
+ private void ApplyLinkState(bool isActive)
+ {
+ LinkStatusText.Text = isActive
+ ? "Защищённая связь активна"
+ : "Связь отключена вручную";
+ LinkStatusText.Opacity = isActive ? 1 : 0.68;
+ DisconnectButton.IsEnabled = isActive;
+ TerminalButton.IsEnabled = isActive;
+ ShowInfo(
+ isActive ? "Связь включена" : "Hermes отключён от Home Lab",
+ isActive
+ ? "Маршрут доступен только для SSH-порта 20202."
+ : "Желаемое состояние сохранено: Disabled. Оба агента должны подтвердить удаление peer и маршрута.",
+ isActive ? InfoBarSeverity.Success : InfoBarSeverity.Warning);
+ }
+
+ private void MainNavigation_SelectionChanged(NavigationView sender, NavigationViewSelectionChangedEventArgs args)
+ {
+ if (args.IsSettingsSelected)
+ {
+ ShowInfo(
+ "Настройки",
+ "Профили серверов хранятся локально; приватный SSH-ключ не покидает этот ПК.",
+ InfoBarSeverity.Informational);
+ }
+ }
+}
diff --git a/src/ServerMonitorManager.Desktop/MainWindow.xaml b/src/ServerMonitorManager.Desktop/MainWindow.xaml
new file mode 100644
index 0000000..39aad8e
--- /dev/null
+++ b/src/ServerMonitorManager.Desktop/MainWindow.xaml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ServerMonitorManager.Desktop/MainWindow.xaml.cs b/src/ServerMonitorManager.Desktop/MainWindow.xaml.cs
new file mode 100644
index 0000000..57b2d09
--- /dev/null
+++ b/src/ServerMonitorManager.Desktop/MainWindow.xaml.cs
@@ -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;
+
+///
+/// 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.
+///
+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));
+ }
+}
diff --git a/src/ServerMonitorManager.Desktop/Package.appxmanifest b/src/ServerMonitorManager.Desktop/Package.appxmanifest
new file mode 100644
index 0000000..20f5ac5
--- /dev/null
+++ b/src/ServerMonitorManager.Desktop/Package.appxmanifest
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+
+
+
+ Server Monitor Manager
+ Ochenstarik UI
+ Assets\StoreLogo.png
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ServerMonitorManager.Desktop/Properties/launchSettings.json b/src/ServerMonitorManager.Desktop/Properties/launchSettings.json
new file mode 100644
index 0000000..269ec8a
--- /dev/null
+++ b/src/ServerMonitorManager.Desktop/Properties/launchSettings.json
@@ -0,0 +1,10 @@
+{
+ "profiles": {
+ "ServerMonitorManager.Desktop (Package)": {
+ "commandName": "MsixPackage"
+ },
+ "ServerMonitorManager.Desktop (Unpackaged)": {
+ "commandName": "Project"
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/ServerMonitorManager.Desktop/ServerMonitorManager.Desktop.csproj b/src/ServerMonitorManager.Desktop/ServerMonitorManager.Desktop.csproj
new file mode 100644
index 0000000..547909a
--- /dev/null
+++ b/src/ServerMonitorManager.Desktop/ServerMonitorManager.Desktop.csproj
@@ -0,0 +1,73 @@
+
+
+ WinExe
+ net10.0-windows10.0.26100.0
+ 10.0.17763.0
+ ServerMonitorManager_Desktop
+ app.manifest
+ x86;x64;ARM64
+ win-$([System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture.ToString().ToLowerInvariant())
+ win-$(Platform).pubxml
+ true
+ false
+ true
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ true
+
+
+
+
+ False
+ True
+ False
+ True
+
+
\ No newline at end of file
diff --git a/src/ServerMonitorManager.Desktop/ServerStorage.cs b/src/ServerMonitorManager.Desktop/ServerStorage.cs
new file mode 100644
index 0000000..ed84eca
--- /dev/null
+++ b/src/ServerMonitorManager.Desktop/ServerStorage.cs
@@ -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> LoadAsync()
+ {
+ try
+ {
+ var file = await ApplicationData.Current.LocalFolder.GetFileAsync(FileName);
+ var json = await FileIO.ReadTextAsync(file);
+ return JsonSerializer.Deserialize>(json, JsonOptions) ?? [];
+ }
+ catch (FileNotFoundException)
+ {
+ return [];
+ }
+ catch (JsonException)
+ {
+ return [];
+ }
+ }
+
+ public async Task SaveAsync(IEnumerable servers)
+ {
+ var file = await ApplicationData.Current.LocalFolder.CreateFileAsync(
+ FileName,
+ CreationCollisionOption.ReplaceExisting);
+ await FileIO.WriteTextAsync(file, JsonSerializer.Serialize(servers, JsonOptions));
+ }
+}
diff --git a/src/ServerMonitorManager.Desktop/ServerViewModel.cs b/src/ServerMonitorManager.Desktop/ServerViewModel.cs
new file mode 100644
index 0000000..d271c4f
--- /dev/null
+++ b/src/ServerMonitorManager.Desktop/ServerViewModel.cs
@@ -0,0 +1,48 @@
+using System.ComponentModel;
+using System.Runtime.CompilerServices;
+
+namespace ServerMonitorManager_Desktop;
+
+public sealed record ServerProfileData(
+ string Id,
+ string Name,
+ string Host,
+ int Port,
+ string User);
+
+public sealed class ServerViewModel : INotifyPropertyChanged
+{
+ private string _status = "Ожидает проверки";
+ private string _cpuText = "—";
+ private double _cpuPercent;
+ private string _memoryText = "—";
+ private string _diskText = "—";
+ private string _latencyText = "—";
+ private bool _isOnline;
+
+ 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 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 bool IsOnline { get => _isOnline; set => Set(ref _isOnline, value); }
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ private void Set(ref T field, T value, [CallerMemberName] string? propertyName = null)
+ {
+ if (EqualityComparer.Default.Equals(field, value))
+ {
+ return;
+ }
+
+ field = value;
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
+ }
+}
diff --git a/src/ServerMonitorManager.Desktop/SshMonitorService.cs b/src/ServerMonitorManager.Desktop/SshMonitorService.cs
new file mode 100644
index 0000000..ac8e42a
--- /dev/null
+++ b/src/ServerMonitorManager.Desktop/SshMonitorService.cs
@@ -0,0 +1,192 @@
+using System.Diagnostics;
+using System.Globalization;
+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,
+ TimeSpan Uptime,
+ TimeSpan Latency);
+
+public sealed partial class SshMonitorService
+{
+ private const string KeyFileName = "server-monitor-manager-ed25519";
+
+ public async Task 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";
+
+ if (!File.Exists(privateKeyPath) || !File.Exists(publicKeyPath))
+ {
+ 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);
+ }
+
+ return (await File.ReadAllTextAsync(publicKeyPath, cancellationToken)).Trim();
+ }
+
+ public async Task QueryAsync(
+ ServerProfileData profile,
+ CancellationToken cancellationToken = default)
+ {
+ ValidateProfile(profile);
+ await EnsureKeyPairAsync(cancellationToken);
+
+ var localFolder = ApplicationData.Current.LocalFolder.Path;
+ var privateKeyPath = Path.Combine(localFolder, "ssh", KeyFileName);
+ 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,
+ "metrics"
+ };
+
+ var stopwatch = Stopwatch.StartNew();
+ var output = await RunProcessAsync(
+ ResolveOpenSshTool("ssh.exe"),
+ arguments,
+ 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");
+
+ return new ServerMetrics(
+ values.GetValueOrDefault("HOSTNAME", profile.Name),
+ cpuPercent,
+ Math.Max(0, memoryTotal - memoryAvailable),
+ memoryTotal,
+ Math.Max(0, diskTotal - diskAvailable),
+ diskTotal,
+ TimeSpan.FromSeconds(ReadLong(values, "UPTIME_SECONDS")),
+ stopwatch.Elapsed);
+ }
+
+ 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 RunProcessAsync(
+ string fileName,
+ IEnumerable 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 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 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();
+}
diff --git a/src/ServerMonitorManager.Desktop/app.manifest b/src/ServerMonitorManager.Desktop/app.manifest
new file mode 100644
index 0000000..5b8a3a7
--- /dev/null
+++ b/src/ServerMonitorManager.Desktop/app.manifest
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ PerMonitorV2
+
+
+