Add Windows SSH monitoring MVP #1
14
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
.vs/
|
||||
.idea/
|
||||
.vscode/
|
||||
bin/
|
||||
obj/
|
||||
artifacts/
|
||||
TestResults/
|
||||
*.user
|
||||
*.suo
|
||||
*.pfx
|
||||
*.key
|
||||
*.pem
|
||||
*.token
|
||||
appsettings.Production.json
|
||||
90
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.
|
||||
|
|
|
|||
162
deploy/ochenstarik-server-monitor-manager.sh
Normal 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
|
|
@ -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.
|
||||
39
docs/installer-contract.md
Normal 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
|
|
@ -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
|
|
@ -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.
|
||||
54
src/ServerMonitorManager.Desktop/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
## .NET / Visual Studio
|
||||
[Bb]in/
|
||||
[Oo]bj/
|
||||
[Dd]ebug/
|
||||
[Rr]elease/
|
||||
.vs/
|
||||
*.user
|
||||
*.suo
|
||||
*.userosscache
|
||||
*.sln.docstates
|
||||
artifacts/
|
||||
|
||||
# Build logs
|
||||
[Ll]og/
|
||||
[Ll]ogs/
|
||||
*.log
|
||||
*.binlog
|
||||
|
||||
# Test results
|
||||
[Tt]est[Rr]esult*/
|
||||
*.trx
|
||||
*.coverage
|
||||
*.coveragexml
|
||||
|
||||
# NuGet
|
||||
*.nupkg
|
||||
*.snupkg
|
||||
*.nuget.props
|
||||
*.nuget.targets
|
||||
project.lock.json
|
||||
|
||||
# MSIX packaging output
|
||||
AppPackages/
|
||||
BundleArtifacts/
|
||||
*.msix
|
||||
*.msixupload
|
||||
*.appx
|
||||
*.appxbundle
|
||||
*.appxupload
|
||||
|
||||
# Publish output
|
||||
publish/
|
||||
*.pubxml
|
||||
PublishScripts/
|
||||
|
||||
# Code analysis and tooling
|
||||
_ReSharper*/
|
||||
*.DotSettings.user
|
||||
*.dotCover
|
||||
.idea/
|
||||
|
||||
# Generated files
|
||||
Generated\ Files/
|
||||
*_wpftmp.csproj
|
||||
16
src/ServerMonitorManager.Desktop/App.xaml
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Application
|
||||
x:Class="ServerMonitorManager_Desktop.App"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="using:ServerMonitorManager_Desktop">
|
||||
<Application.Resources>
|
||||
<ResourceDictionary>
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
<XamlControlsResources xmlns="using:Microsoft.UI.Xaml.Controls" />
|
||||
<!-- Other merged dictionaries here -->
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
<!-- Other app resources here -->
|
||||
</ResourceDictionary>
|
||||
</Application.Resources>
|
||||
</Application>
|
||||
44
src/ServerMonitorManager.Desktop/App.xaml.cs
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
using Windows.ApplicationModel;
|
||||
using Windows.ApplicationModel.Activation;
|
||||
using Windows.Foundation;
|
||||
using Windows.Foundation.Collections;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
using Microsoft.UI.Xaml.Controls.Primitives;
|
||||
using Microsoft.UI.Xaml.Data;
|
||||
using Microsoft.UI.Xaml.Input;
|
||||
using Microsoft.UI.Xaml.Media;
|
||||
using Microsoft.UI.Xaml.Navigation;
|
||||
using Microsoft.UI.Xaml.Shapes;
|
||||
|
||||
// To learn more about WinUI, the WinUI project structure,
|
||||
// and more about our project templates, see: http://aka.ms/winui-project-info.
|
||||
|
||||
namespace ServerMonitorManager_Desktop;
|
||||
|
||||
/// <summary>
|
||||
/// Provides application-specific behavior to supplement the default Application class.
|
||||
/// </summary>
|
||||
public partial class App : Application
|
||||
{
|
||||
private Window? _window;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the singleton application object. This is the first line of authored code
|
||||
/// executed, and as such is the logical equivalent of main() or WinMain().
|
||||
/// </summary>
|
||||
public App()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invoked when the application is launched.
|
||||
/// </summary>
|
||||
/// <param name="args">Details about the launch request and process.</param>
|
||||
protected override void OnLaunched(Microsoft.UI.Xaml.LaunchActivatedEventArgs args)
|
||||
{
|
||||
_window = new MainWindow();
|
||||
_window.Activate();
|
||||
}
|
||||
}
|
||||
BIN
src/ServerMonitorManager.Desktop/Assets/AppIcon.ico
Normal file
|
After Width: | Height: | Size: 361 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 574 B |
|
After Width: | Height: | Size: 1.1 KiB |
BIN
src/ServerMonitorManager.Desktop/Assets/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
293
src/ServerMonitorManager.Desktop/MainPage.xaml
Normal 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>
|
||||
291
src/ServerMonitorManager.Desktop/MainPage.xaml.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
34
src/ServerMonitorManager.Desktop/MainWindow.xaml
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<Window
|
||||
x:Class="ServerMonitorManager_Desktop.MainWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:local="using:ServerMonitorManager_Desktop"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
Title="Server Monitor Manager"
|
||||
mc:Ignorable="d">
|
||||
<Window.SystemBackdrop>
|
||||
<MicaBackdrop />
|
||||
</Window.SystemBackdrop>
|
||||
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<TitleBar x:Name="AppTitleBar" Title="Server Monitor Manager">
|
||||
<TitleBar.IconSource>
|
||||
<ImageIconSource ImageSource="Assets/AppIcon.ico" />
|
||||
</TitleBar.IconSource>
|
||||
</TitleBar>
|
||||
|
||||
<!--
|
||||
The Frame hosts pages for your application content. Add your UI to
|
||||
MainPage.xaml rather than here so you can use Page features such as
|
||||
navigation events and the Loaded lifecycle.
|
||||
-->
|
||||
<Frame x:Name="RootFrame" Grid.Row="1" />
|
||||
</Grid>
|
||||
</Window>
|
||||
29
src/ServerMonitorManager.Desktop/MainWindow.xaml.cs
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
using Microsoft.UI.Xaml;
|
||||
using Windows.Graphics;
|
||||
|
||||
// To learn more about WinUI, the WinUI project structure,
|
||||
// and more about our project templates, see: http://aka.ms/winui-project-info.
|
||||
|
||||
namespace ServerMonitorManager_Desktop;
|
||||
|
||||
/// <summary>
|
||||
/// The application window. This hosts a Frame that displays pages. Add your
|
||||
/// UI and logic to MainPage.xaml / MainPage.xaml.cs instead of here so you
|
||||
/// can use Page features such as navigation events and the Loaded lifecycle.
|
||||
/// </summary>
|
||||
public sealed partial class MainWindow : Window
|
||||
{
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
ExtendsContentIntoTitleBar = true;
|
||||
SetTitleBar(AppTitleBar);
|
||||
|
||||
AppWindow.SetIcon("Assets/AppIcon.ico");
|
||||
AppWindow.Resize(new SizeInt32(1280, 820));
|
||||
|
||||
// Navigate the root frame to the main page on startup.
|
||||
RootFrame.Navigate(typeof(MainPage));
|
||||
}
|
||||
}
|
||||
53
src/ServerMonitorManager.Desktop/Package.appxmanifest
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<Package
|
||||
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
|
||||
xmlns:mp="http://schemas.microsoft.com/appx/2014/phone/manifest"
|
||||
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
|
||||
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
|
||||
IgnorableNamespaces="uap rescap">
|
||||
|
||||
<Identity
|
||||
Name="81AD4B9B-7597-44AB-93A0-D5A695B2D35E"
|
||||
Publisher="CN=AppPublisher"
|
||||
Version="1.0.0.0" />
|
||||
|
||||
<mp:PhoneIdentity PhoneProductId="81AD4B9B-7597-44AB-93A0-D5A695B2D35E" PhonePublisherId="00000000-0000-0000-0000-000000000000"/>
|
||||
|
||||
<Properties>
|
||||
<DisplayName>Server Monitor Manager</DisplayName>
|
||||
<PublisherDisplayName>Ochenstarik UI</PublisherDisplayName>
|
||||
<Logo>Assets\StoreLogo.png</Logo>
|
||||
</Properties>
|
||||
|
||||
<Dependencies>
|
||||
<TargetDeviceFamily Name="Windows.Universal" MinVersion="10.0.17763.0" MaxVersionTested="10.0.26226.0" />
|
||||
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0" MaxVersionTested="10.0.26226.0" />
|
||||
</Dependencies>
|
||||
|
||||
<Resources>
|
||||
<Resource Language="x-generate"/>
|
||||
</Resources>
|
||||
|
||||
<Applications>
|
||||
<Application Id="App"
|
||||
Executable="$targetnametoken$.exe"
|
||||
EntryPoint="$targetentrypoint$">
|
||||
<uap:VisualElements
|
||||
DisplayName="Server Monitor Manager"
|
||||
Description="Лёгкий мониторинг и безопасное управление серверами"
|
||||
BackgroundColor="transparent"
|
||||
Square150x150Logo="Assets\Square150x150Logo.png"
|
||||
Square44x44Logo="Assets\Square44x44Logo.png">
|
||||
<uap:DefaultTile Wide310x150Logo="Assets\Wide310x150Logo.png" />
|
||||
<uap:SplashScreen Image="Assets\SplashScreen.png" />
|
||||
</uap:VisualElements>
|
||||
</Application>
|
||||
</Applications>
|
||||
|
||||
<Capabilities>
|
||||
<Capability Name="internetClient" />
|
||||
<Capability Name="privateNetworkClientServer" />
|
||||
<rescap:Capability Name="runFullTrust" />
|
||||
</Capabilities>
|
||||
</Package>
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"profiles": {
|
||||
"ServerMonitorManager.Desktop (Package)": {
|
||||
"commandName": "MsixPackage"
|
||||
},
|
||||
"ServerMonitorManager.Desktop (Unpackaged)": {
|
||||
"commandName": "Project"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,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>
|
||||
36
src/ServerMonitorManager.Desktop/ServerStorage.cs
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
using System.Text.Json;
|
||||
using Windows.Storage;
|
||||
|
||||
namespace ServerMonitorManager_Desktop;
|
||||
|
||||
public sealed class ServerStorage
|
||||
{
|
||||
private const string FileName = "servers.json";
|
||||
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
|
||||
|
||||
public async Task<IReadOnlyList<ServerProfileData>> LoadAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var file = await ApplicationData.Current.LocalFolder.GetFileAsync(FileName);
|
||||
var json = await FileIO.ReadTextAsync(file);
|
||||
return JsonSerializer.Deserialize<List<ServerProfileData>>(json, JsonOptions) ?? [];
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SaveAsync(IEnumerable<ServerProfileData> servers)
|
||||
{
|
||||
var file = await ApplicationData.Current.LocalFolder.CreateFileAsync(
|
||||
FileName,
|
||||
CreationCollisionOption.ReplaceExisting);
|
||||
await FileIO.WriteTextAsync(file, JsonSerializer.Serialize(servers, JsonOptions));
|
||||
}
|
||||
}
|
||||
48
src/ServerMonitorManager.Desktop/ServerViewModel.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
192
src/ServerMonitorManager.Desktop/SshMonitorService.cs
Normal 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();
|
||||
}
|
||||
19
src/ServerMonitorManager.Desktop/app.manifest
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<assemblyIdentity version="1.0.0.0" name="ServerMonitorManager.Desktop.app"/>
|
||||
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<!-- The ID below informs the system that this application is compatible with OS features first introduced in Windows 10.
|
||||
It is necessary to support features in unpackaged applications, for example the custom titlebar implementation.
|
||||
For more info see https://docs.microsoft.com/windows/apps/windows-app-sdk/use-windows-app-sdk-run-time#declare-os-compatibility-in-your-application-manifest -->
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||
</application>
|
||||
</compatibility>
|
||||
|
||||
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<windowsSettings>
|
||||
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
|
||||
</windowsSettings>
|
||||
</application>
|
||||
</assembly>
|
||||