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