Add Windows SSH monitoring MVP

This commit is contained in:
Ochenstarik 2026-07-15 13:27:01 +07:00
parent 1460386706
commit e8db9f46aa
30 changed files with 1671 additions and 19 deletions

14
.gitignore vendored Normal file
View file

@ -0,0 +1,14 @@
.vs/
.idea/
.vscode/
bin/
obj/
artifacts/
TestResults/
*.user
*.suo
*.pfx
*.key
*.pem
*.token
appsettings.Production.json

View file

@ -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; - CPU/load, память, корневой диск, задержка и uptime через ограниченный SSH forced-command;
- `src/ServerManager.Agent/` — исходящий агент управляемого Linux-сервера; - SSH-терминал и сохранённые подключения без передачи приватных ключей серверу управления;
- `deploy/` — установщик и systemd-файлы агента; - группы серверов, теги, избранное и сводные предупреждения;
- `docs/` — архитектура и модель безопасности. - явное включение и ручное отключение защищённых связей между серверами;
- ограниченный доступ автоматизированных инструментов, например 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.

View file

@ -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 "$@"

85
docs/architecture.md Normal file
View file

@ -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 каждые 515 секунд. 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 в интернет;
- работа при 50100 серверах на одном небольшом control node;
- деградация без потери управления: графики могут иметь пробелы, но команды не выполняются повторно без idempotency key.

View file

@ -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.

52
docs/roadmap.md Normal file
View file

@ -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-провайдеру.

56
docs/security-model.md Normal file
View file

@ -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.

View 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

View 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>

View 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();
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 361 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 574 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

View file

@ -0,0 +1,293 @@
<?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: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="Добавить сервер"
Click="AddServerButton_Click"
Icon="Add"
Label="Добавить сервер" />
</CommandBar>
</Grid>
<InfoBar
x:Name="LinkActionInfo"
Grid.Row="1"
Margin="0,16,0,0"
IsClosable="True"
IsOpen="False"
Severity="Informational" />
<Border
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 FontSize="24" FontWeight="SemiBold" Text="1" />
<TextBlock Foreground="{ThemeResource TextFillColorSecondaryBrush}" Text="прототип Links" />
</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 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}"
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}" />
</StackPanel>
<TextBlock
Grid.Column="4"
VerticalAlignment="Center"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="{Binding LatencyText}" />
</Grid>
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
</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="Hermes → Home" />
<TextBlock
x:Name="LinkStatusText"
Foreground="{ThemeResource SystemFillColorSuccessBrush}"
Text="Защищённая связь активна" />
</StackPanel>
<ToggleSwitch
x:Name="ManualLinkToggle"
HorizontalAlignment="Right"
AutomationProperties.Name="Подключение Hermes к Home Lab"
IsOn="True" />
</Grid>
<Rectangle Height="1" Fill="{ThemeResource DividerStrokeColorDefaultBrush}" />
<Grid RowSpacing="10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Foreground="{ThemeResource TextFillColorSecondaryBrush}" Text="Назначение" />
<TextBlock Grid.Column="1" HorizontalAlignment="Right" Text="Разработка" />
<TextBlock Grid.Row="1" Foreground="{ThemeResource TextFillColorSecondaryBrush}" Text="Доступ" />
<TextBlock Grid.Row="1" Grid.Column="1" HorizontalAlignment="Right" Text="10.70.0.12:20202/TCP" />
<TextBlock Grid.Row="2" Foreground="{ThemeResource TextFillColorSecondaryBrush}" Text="Идентичность" />
<TextBlock Grid.Row="2" Grid.Column="1" HorizontalAlignment="Right" Text="hermes-dev" />
<TextBlock Grid.Row="3" Foreground="{ThemeResource TextFillColorSecondaryBrush}" Text="Режим" />
<TextBlock Grid.Row="3" Grid.Column="1" HorizontalAlignment="Right" Text="Только вручную" />
</Grid>
<InfoBar
IsClosable="False"
IsOpen="True"
Message="Отключение удалит peer и маршрут на обоих серверах. SSH-ключ Hermes останется отдельным уровнем доступа."
Severity="Informational" />
<Button
x:Name="TerminalButton"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
Content="Открыть SSH-терминал"
Style="{StaticResource AccentButtonStyle}" />
<Button
x:Name="DisconnectButton"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
Click="DisconnectButton_Click"
Content="Отключить связь" />
</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>
</Grid>
</NavigationView>
</Page>

View file

@ -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<ServerViewModel> 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);
}
}
}

View 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>

View 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));
}
}

View 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>

View file

@ -0,0 +1,10 @@
{
"profiles": {
"ServerMonitorManager.Desktop (Package)": {
"commandName": "MsixPackage"
},
"ServerMonitorManager.Desktop (Unpackaged)": {
"commandName": "Project"
}
}
}

View file

@ -0,0 +1,73 @@
<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>
<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\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>
<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" />
</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>

View 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));
}
}

View file

@ -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<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));
}
}

View file

@ -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<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";
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<ServerMetrics> 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<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();
}

View 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>