Add directed server mesh controls
This commit is contained in:
parent
d7e8a3bada
commit
1f0eaedaf1
7 changed files with 269 additions and 291 deletions
57
README.md
57
README.md
|
|
@ -15,18 +15,14 @@
|
||||||
## Архитектура
|
## Архитектура
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Windows client
|
Windows client -- ограниченный SSH --> главный WireGuard Hub с белым IP
|
||||||
|
|
^
|
||||||
| HTTPS / WebSocket, mTLS
|
| исходящие WireGuard-соединения
|
||||||
v
|
|
|
||||||
Control node (может работать на одном из ваших серверов)
|
Hermes / Home / другие серверы
|
||||||
^
|
|
||||||
| только исходящие соединения агентов
|
|
||||||
|
|
|
||||||
Linux agents ---- управляемые WireGuard links ---- Linux agents
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Мониторинг и межсерверные связи разделены. Регистрация агента не создаёт сетевого туннеля и не выдаёт доступ к SSH. Каждая связь создаётся отдельно, имеет список разрешённых адресов и портов, может иметь срок действия и всегда поддерживает ручной `Disconnect`.
|
Hub объединяет узлы в приватной подсети, но по умолчанию запрещает транзит между ними. Каждая связь `источник → цель` включается и отключается отдельно. Поэтому можно оставить `Hermes → Home`, отключив только `Hermes → Server2`. Обратное направление не появляется автоматически.
|
||||||
|
|
||||||
Подробности: [архитектура](docs/architecture.md), [модель безопасности](docs/security-model.md), [план разработки](docs/roadmap.md) и [контракт Linux-установщика](docs/installer-contract.md).
|
Подробности: [архитектура](docs/architecture.md), [модель безопасности](docs/security-model.md), [план разработки](docs/roadmap.md) и [контракт Linux-установщика](docs/installer-contract.md).
|
||||||
|
|
||||||
|
|
@ -47,44 +43,45 @@ tests/
|
||||||
|
|
||||||
Проект находится на стадии первого тестируемого Windows MVP. Репозиторий переименован из `mobile-server-manager`, потому что первой целевой платформой теперь является Windows. Клиент создаёт отдельный Ed25519-ключ, сохраняет профили локально и получает живые метрики серверов через системный OpenSSH.
|
Проект находится на стадии первого тестируемого 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 или право выполнять произвольные команды.
|
Установочный скрипт Linux-части хранится в репозитории [`ochenstarik-ui/lightweight-server`](https://github.com/ochenstarik-ui/lightweight-server) под именем `ochenstarik-server-monitor-manager.sh`. Он устанавливает режим Hub или Node, создаёт отдельного пользователя `ochenstarik-monitor` и SSH forced-command: ключ приложения не получает shell, PTY, port forwarding или право выполнять произвольные команды.
|
||||||
|
|
||||||
## Быстрый тест на трёх серверах
|
## Быстрый тест на трёх серверах
|
||||||
|
|
||||||
1. Запустите Windows-клиент и нажмите `SSH-ключ` → `Копировать`.
|
1. Запустите Windows-клиент и нажмите `SSH-ключ` → `Копировать`.
|
||||||
2. На каждом Ubuntu/Debian-сервере запустите одну команду:
|
2. На сервере с белым IP скачайте установщик и выберите режим Hub:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL \
|
curl -fLO https://raw.githubusercontent.com/ochenstarik-ui/lightweight-server/agent/server-monitor-installer/ochenstarik-server-monitor-manager.sh
|
||||||
https://raw.githubusercontent.com/ochenstarik-ui/lightweight-server/agent/server-monitor-installer/ochenstarik-server-monitor-manager.sh \
|
chmod 700 ochenstarik-server-monitor-manager.sh
|
||||||
| sudo bash -s -- install
|
sudo ./ochenstarik-server-monitor-manager.sh hub
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Вставьте скопированный публичный ключ по запросу скрипта.
|
3. Откройте выбранный UDP-порт WireGuard (по умолчанию `51820`) и создайте коды узлов:
|
||||||
4. В приложении нажмите `Добавить сервер`, укажите IP/домен, текущий SSH-порт и пользователя `ochenstarik-monitor`.
|
|
||||||
5. Повторите для остальных серверов и нажмите `Обновить`.
|
```bash
|
||||||
|
sudo ochenstarik-smm node-code hermes
|
||||||
|
sudo ochenstarik-smm node-code home
|
||||||
|
sudo ochenstarik-smm node-code server2
|
||||||
|
```
|
||||||
|
|
||||||
|
4. На каждом вторичном сервере запустите `sudo ./ochenstarik-server-monitor-manager.sh node`, вставьте SSH-ключ приложения и код именно этого узла. Белый IP вторичным серверам не нужен.
|
||||||
|
5. В приложении добавьте главный сервер с пользователем `ochenstarik-monitor` и отметьте `Это главный Mesh Hub`.
|
||||||
|
6. Нажмите обновление в панели связей, выберите источник и цель, затем `Разрешить` или `Отключить`.
|
||||||
|
|
||||||
Приватный ключ хранится в локальном каталоге packaged-приложения Windows. Первый SSH-host key принимается в режиме `accept-new`, затем проверяется по отдельному `known_hosts` приложения.
|
Приватный ключ хранится в локальном каталоге packaged-приложения Windows. Первый SSH-host key принимается в режиме `accept-new`, затем проверяется по отдельному `known_hosts` приложения.
|
||||||
|
|
||||||
Для повторяемой установки без ручного ввода публичный ключ можно передать через окружение. Это публичная часть ключа, не секрет:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -fsSL \
|
|
||||||
https://raw.githubusercontent.com/ochenstarik-ui/lightweight-server/agent/server-monitor-installer/ochenstarik-server-monitor-manager.sh \
|
|
||||||
| sudo env SERVER_MONITOR_PUBLIC_KEY='ssh-ed25519 AAAA... server-monitor-manager' \
|
|
||||||
bash -s -- install
|
|
||||||
```
|
|
||||||
|
|
||||||
Для предварительной проверки вместо запуска через pipe скачайте файл, выполните `bash -n` и просмотрите его содержимое.
|
Для предварительной проверки вместо запуска через pipe скачайте файл, выполните `bash -n` и просмотрите его содержимое.
|
||||||
|
|
||||||
|
Код Node содержит приватный ключ WireGuard узла. Считайте его секретом, используйте только на предназначенном сервере и не сохраняйте в Git или истории сообщений.
|
||||||
|
|
||||||
## Принципы безопасности
|
## Принципы безопасности
|
||||||
|
|
||||||
- никаких общих root-паролей и приватных SSH-ключей в control node;
|
- никаких общих root-паролей и приватных SSH-ключей на Hub;
|
||||||
- на первом SSH-MVP — отдельный ключ мониторинга и pinned `known_hosts`; enrollment tokens появятся вместе с постоянным агентом;
|
- отдельный ключ мониторинга и pinned `known_hosts`; код регистрации Node передаётся один раз вручную;
|
||||||
- отдельная идентичность для пользователя, устройства, агента и автоматизации;
|
- отдельная идентичность для пользователя, устройства, агента и автоматизации;
|
||||||
- опасные действия требуют повторного подтверждения и попадают в неизменяемый аудит;
|
- опасные действия требуют повторного подтверждения и попадают в неизменяемый аудит;
|
||||||
- доступ Hermes выдаётся отдельному Unix-пользователю и SSH-ключу с минимальными правами;
|
- доступ Hermes выдаётся отдельному Unix-пользователю и SSH-ключу с минимальными правами;
|
||||||
- отключение Link удаляет маршруты и peer-конфигурацию, а не только скрывает кнопку в интерфейсе.
|
- отключение Link сразу удаляет разрешённую пару из nftables на Hub, не затрагивая другие связи узла.
|
||||||
|
|
||||||
## Лицензия и секреты
|
## Лицензия и секреты
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,175 +0,0 @@
|
||||||
#!/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() {
|
|
||||||
if [[ -n "${SERVER_MONITOR_PUBLIC_KEY:-}" ]]; then
|
|
||||||
PUBLIC_KEY="$SERVER_MONITOR_PUBLIC_KEY"
|
|
||||||
log "Публичный ключ получен из SERVER_MONITOR_PUBLIC_KEY"
|
|
||||||
else
|
|
||||||
printf '\nВ Windows-приложении нажмите «SSH-ключ» → «Копировать».\n'
|
|
||||||
if [[ -r /dev/tty ]]; then
|
|
||||||
IFS= read -r -p 'Вставьте публичный ключ: ' PUBLIC_KEY < /dev/tty
|
|
||||||
else
|
|
||||||
die "Нет интерактивного терминала. Передайте ключ через SERVER_MONITOR_PUBLIC_KEY"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
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
|
|
||||||
if [[ -r /dev/tty ]]; then
|
|
||||||
IFS= read -r -p 'Удалить пользователя мониторинга и forced-command? [y/N]: ' answer < /dev/tty
|
|
||||||
else
|
|
||||||
die "Для удаления требуется интерактивный терминал"
|
|
||||||
fi
|
|
||||||
[[ "$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 "$@"
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
xmlns:local="using:ServerMonitorManager_Desktop"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
mc:Ignorable="d">
|
mc:Ignorable="d">
|
||||||
|
|
||||||
|
|
@ -122,8 +123,8 @@
|
||||||
<Rectangle Grid.Column="3" Fill="{ThemeResource DividerStrokeColorDefaultBrush}" />
|
<Rectangle Grid.Column="3" Fill="{ThemeResource DividerStrokeColorDefaultBrush}" />
|
||||||
<StackPanel Grid.Column="4" Spacing="4">
|
<StackPanel Grid.Column="4" Spacing="4">
|
||||||
<TextBlock Foreground="{ThemeResource TextFillColorSecondaryBrush}" Text="Активные связи" />
|
<TextBlock Foreground="{ThemeResource TextFillColorSecondaryBrush}" Text="Активные связи" />
|
||||||
<TextBlock FontSize="24" FontWeight="SemiBold" Text="1" />
|
<TextBlock x:Name="ActiveLinksValueText" FontSize="24" FontWeight="SemiBold" Text="0" />
|
||||||
<TextBlock Foreground="{ThemeResource TextFillColorSecondaryBrush}" Text="прототип Links" />
|
<TextBlock Foreground="{ThemeResource TextFillColorSecondaryBrush}" Text="разрешённые направления" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<Rectangle Grid.Column="5" Fill="{ThemeResource DividerStrokeColorDefaultBrush}" />
|
<Rectangle Grid.Column="5" Fill="{ThemeResource DividerStrokeColorDefaultBrush}" />
|
||||||
<StackPanel Grid.Column="6" Spacing="4">
|
<StackPanel Grid.Column="6" Spacing="4">
|
||||||
|
|
@ -214,60 +215,71 @@
|
||||||
<StackPanel Spacing="18">
|
<StackPanel Spacing="18">
|
||||||
<Grid>
|
<Grid>
|
||||||
<StackPanel Spacing="3">
|
<StackPanel Spacing="3">
|
||||||
<TextBlock Style="{StaticResource SubtitleTextBlockStyle}" Text="Hermes → Home" />
|
<TextBlock Style="{StaticResource SubtitleTextBlockStyle}" Text="Связи серверов" />
|
||||||
<TextBlock
|
<TextBlock
|
||||||
x:Name="LinkStatusText"
|
x:Name="MeshStatusText"
|
||||||
Foreground="{ThemeResource SystemFillColorSuccessBrush}"
|
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||||
Text="Защищённая связь активна" />
|
Text="Добавьте главный Mesh Hub" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<ToggleSwitch
|
<Button
|
||||||
x:Name="ManualLinkToggle"
|
|
||||||
HorizontalAlignment="Right"
|
HorizontalAlignment="Right"
|
||||||
AutomationProperties.Name="Подключение Hermes к Home Lab"
|
AutomationProperties.Name="Обновить список серверов и связей"
|
||||||
IsOn="True" />
|
Click="RefreshMeshButton_Click">
|
||||||
|
<FontIcon Glyph="" />
|
||||||
|
</Button>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Rectangle Height="1" Fill="{ThemeResource DividerStrokeColorDefaultBrush}" />
|
<Rectangle Height="1" Fill="{ThemeResource DividerStrokeColorDefaultBrush}" />
|
||||||
|
|
||||||
<Grid RowSpacing="10">
|
<ComboBox
|
||||||
|
x:Name="SourceNodeBox"
|
||||||
|
HorizontalAlignment="Stretch"
|
||||||
|
DisplayMemberPath="Label"
|
||||||
|
Header="Источник доступа"
|
||||||
|
ItemsSource="{x:Bind MeshNodes}" />
|
||||||
|
<ComboBox
|
||||||
|
x:Name="TargetNodeBox"
|
||||||
|
HorizontalAlignment="Stretch"
|
||||||
|
DisplayMemberPath="Label"
|
||||||
|
Header="Целевой сервер"
|
||||||
|
ItemsSource="{x:Bind MeshNodes}" />
|
||||||
|
|
||||||
|
<Grid ColumnSpacing="8">
|
||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
<ColumnDefinition Width="Auto" />
|
<ColumnDefinition Width="*" />
|
||||||
<ColumnDefinition Width="*" />
|
<ColumnDefinition Width="*" />
|
||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
<Grid.RowDefinitions>
|
<Button
|
||||||
<RowDefinition Height="Auto" />
|
HorizontalAlignment="Stretch"
|
||||||
<RowDefinition Height="Auto" />
|
HorizontalContentAlignment="Center"
|
||||||
<RowDefinition Height="Auto" />
|
Click="ConnectLinkButton_Click"
|
||||||
<RowDefinition Height="Auto" />
|
Content="Разрешить"
|
||||||
</Grid.RowDefinitions>
|
Style="{StaticResource AccentButtonStyle}" />
|
||||||
<TextBlock Foreground="{ThemeResource TextFillColorSecondaryBrush}" Text="Назначение" />
|
<Button
|
||||||
<TextBlock Grid.Column="1" HorizontalAlignment="Right" Text="Разработка" />
|
Grid.Column="1"
|
||||||
<TextBlock Grid.Row="1" Foreground="{ThemeResource TextFillColorSecondaryBrush}" Text="Доступ" />
|
HorizontalAlignment="Stretch"
|
||||||
<TextBlock Grid.Row="1" Grid.Column="1" HorizontalAlignment="Right" Text="10.70.0.12:20202/TCP" />
|
HorizontalContentAlignment="Center"
|
||||||
<TextBlock Grid.Row="2" Foreground="{ThemeResource TextFillColorSecondaryBrush}" Text="Идентичность" />
|
Click="DisconnectLinkButton_Click"
|
||||||
<TextBlock Grid.Row="2" Grid.Column="1" HorizontalAlignment="Right" Text="hermes-dev" />
|
Content="Отключить" />
|
||||||
<TextBlock Grid.Row="3" Foreground="{ThemeResource TextFillColorSecondaryBrush}" Text="Режим" />
|
|
||||||
<TextBlock Grid.Row="3" Grid.Column="1" HorizontalAlignment="Right" Text="Только вручную" />
|
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
<TextBlock FontWeight="SemiBold" Text="Разрешённые направления" />
|
||||||
|
<ListView
|
||||||
|
MaxHeight="220"
|
||||||
|
ItemsSource="{x:Bind MeshLinks}"
|
||||||
|
SelectionMode="None">
|
||||||
|
<ListView.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="local:MeshLinkViewModel">
|
||||||
|
<TextBlock Margin="4,8" Text="{x:Bind Label}" />
|
||||||
|
</DataTemplate>
|
||||||
|
</ListView.ItemTemplate>
|
||||||
|
</ListView>
|
||||||
|
|
||||||
<InfoBar
|
<InfoBar
|
||||||
IsClosable="False"
|
IsClosable="False"
|
||||||
IsOpen="True"
|
IsOpen="True"
|
||||||
Message="Отключение удалит peer и маршрут на обоих серверах. SSH-ключ Hermes останется отдельным уровнем доступа."
|
Message="Связь направленная: Hermes → Home разрешает Hermes обращаться к Home. Обратное направление включается отдельно."
|
||||||
Severity="Informational" />
|
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>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,12 +16,13 @@ public sealed partial class MainPage : Page
|
||||||
public MainPage()
|
public MainPage()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
ManualLinkToggle.Toggled += ManualLinkToggle_Toggled;
|
|
||||||
Loaded += MainPage_Loaded;
|
Loaded += MainPage_Loaded;
|
||||||
MainNavigation.SelectedItem = MainNavigation.MenuItems[0];
|
MainNavigation.SelectedItem = MainNavigation.MenuItems[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
public ObservableCollection<ServerViewModel> Servers { get; } = [];
|
public ObservableCollection<ServerViewModel> Servers { get; } = [];
|
||||||
|
public ObservableCollection<MeshNodeViewModel> MeshNodes { get; } = [];
|
||||||
|
public ObservableCollection<MeshLinkViewModel> MeshLinks { get; } = [];
|
||||||
|
|
||||||
private async void MainPage_Loaded(object sender, RoutedEventArgs e)
|
private async void MainPage_Loaded(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
|
|
@ -116,12 +117,17 @@ public sealed partial class MainPage : Page
|
||||||
SpinButtonPlacementMode = NumberBoxSpinButtonPlacementMode.Compact
|
SpinButtonPlacementMode = NumberBoxSpinButtonPlacementMode.Compact
|
||||||
};
|
};
|
||||||
var userBox = new TextBox { Header = "Пользователь", Text = "ochenstarik-monitor" };
|
var userBox = new TextBox { Header = "Пользователь", Text = "ochenstarik-monitor" };
|
||||||
|
var hubBox = new CheckBox
|
||||||
|
{
|
||||||
|
Content = "Это главный Mesh Hub"
|
||||||
|
};
|
||||||
|
AutomationProperties.SetName(hubBox, "Использовать сервер как главный Mesh Hub");
|
||||||
var validationText = new TextBlock { TextWrapping = TextWrapping.Wrap };
|
var validationText = new TextBlock { TextWrapping = TextWrapping.Wrap };
|
||||||
var content = new StackPanel
|
var content = new StackPanel
|
||||||
{
|
{
|
||||||
Spacing = 12,
|
Spacing = 12,
|
||||||
MinWidth = 420,
|
MinWidth = 420,
|
||||||
Children = { nameBox, hostBox, portBox, userBox, validationText }
|
Children = { nameBox, hostBox, portBox, userBox, hubBox, validationText }
|
||||||
};
|
};
|
||||||
var dialog = new ContentDialog
|
var dialog = new ContentDialog
|
||||||
{
|
{
|
||||||
|
|
@ -154,7 +160,8 @@ public sealed partial class MainPage : Page
|
||||||
nameBox.Text.Trim(),
|
nameBox.Text.Trim(),
|
||||||
hostBox.Text.Trim(),
|
hostBox.Text.Trim(),
|
||||||
checked((int)portBox.Value),
|
checked((int)portBox.Value),
|
||||||
userBox.Text.Trim());
|
userBox.Text.Trim(),
|
||||||
|
hubBox.IsChecked == true);
|
||||||
var server = new ServerViewModel(profile);
|
var server = new ServerViewModel(profile);
|
||||||
Servers.Add(server);
|
Servers.Add(server);
|
||||||
await SaveProfilesAsync();
|
await SaveProfilesAsync();
|
||||||
|
|
@ -184,6 +191,10 @@ public sealed partial class MainPage : Page
|
||||||
WarningValueText.Text = warnings.ToString(CultureInfo.InvariantCulture);
|
WarningValueText.Text = warnings.ToString(CultureInfo.InvariantCulture);
|
||||||
WarningDetailText.Text = warnings == 0 ? "Нет предупреждений" : "Проверьте SSH и ключ";
|
WarningDetailText.Text = warnings == 0 ? "Нет предупреждений" : "Проверьте SSH и ключ";
|
||||||
HeaderStatusText.Text = $"SSH monitoring · {Servers.Count} сервер(а) · обновлено {DateTime.Now:HH:mm:ss}";
|
HeaderStatusText.Text = $"SSH monitoring · {Servers.Count} сервер(а) · обновлено {DateTime.Now:HH:mm:ss}";
|
||||||
|
if (Servers.Any(server => server.IsHub))
|
||||||
|
{
|
||||||
|
await RefreshMeshAsync(showSuccess: false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task RefreshServerAsync(ServerViewModel server)
|
private async Task RefreshServerAsync(ServerViewModel server)
|
||||||
|
|
@ -256,26 +267,112 @@ public sealed partial class MainPage : Page
|
||||||
LinkActionInfo.IsOpen = true;
|
LinkActionInfo.IsOpen = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ManualLinkToggle_Toggled(object sender, RoutedEventArgs e)
|
private ServerViewModel? FindHub()
|
||||||
=> ApplyLinkState(ManualLinkToggle.IsOn);
|
=> Servers.FirstOrDefault(server => server.IsHub);
|
||||||
|
|
||||||
private void DisconnectButton_Click(object sender, RoutedEventArgs e)
|
private async Task RefreshMeshAsync(bool showSuccess = true)
|
||||||
=> ManualLinkToggle.IsOn = false;
|
|
||||||
|
|
||||||
private void ApplyLinkState(bool isActive)
|
|
||||||
{
|
{
|
||||||
LinkStatusText.Text = isActive
|
var hub = FindHub();
|
||||||
? "Защищённая связь активна"
|
if (hub is null)
|
||||||
: "Связь отключена вручную";
|
{
|
||||||
LinkStatusText.Opacity = isActive ? 1 : 0.68;
|
ShowInfo("Mesh Hub не выбран", "Добавьте главный сервер или отметьте его как Mesh Hub.", InfoBarSeverity.Warning);
|
||||||
DisconnectButton.IsEnabled = isActive;
|
return;
|
||||||
TerminalButton.IsEnabled = isActive;
|
}
|
||||||
ShowInfo(
|
|
||||||
isActive ? "Связь включена" : "Hermes отключён от Home Lab",
|
try
|
||||||
isActive
|
{
|
||||||
? "Маршрут доступен только для SSH-порта 20202."
|
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15));
|
||||||
: "Желаемое состояние сохранено: Disabled. Оба агента должны подтвердить удаление peer и маршрута.",
|
var nodesOutput = await _ssh.RunRestrictedCommandAsync(hub.Profile, "mesh nodes", timeout.Token);
|
||||||
isActive ? InfoBarSeverity.Success : InfoBarSeverity.Warning);
|
var linksOutput = await _ssh.RunRestrictedCommandAsync(hub.Profile, "mesh links", timeout.Token);
|
||||||
|
MeshNodes.Clear();
|
||||||
|
foreach (var line in nodesOutput.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries))
|
||||||
|
{
|
||||||
|
if (!line.StartsWith("NODE=", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
var fields = line[5..].Split('|');
|
||||||
|
if (fields.Length == 4 && int.TryParse(fields[3], out var age))
|
||||||
|
{
|
||||||
|
MeshNodes.Add(new MeshNodeViewModel(fields[0], fields[1], fields[2], age));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MeshLinks.Clear();
|
||||||
|
foreach (var line in linksOutput.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries))
|
||||||
|
{
|
||||||
|
if (!line.StartsWith("LINK=", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
var fields = line[5..].Split('|');
|
||||||
|
if (fields.Length >= 2)
|
||||||
|
{
|
||||||
|
MeshLinks.Add(new MeshLinkViewModel(fields[0], fields[1]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ActiveLinksValueText.Text = MeshLinks.Count.ToString(CultureInfo.InvariantCulture);
|
||||||
|
MeshStatusText.Text = $"{MeshNodes.Count} узлов · {MeshLinks.Count} активных связей";
|
||||||
|
if (showSuccess)
|
||||||
|
{
|
||||||
|
ShowInfo("Mesh обновлён", MeshStatusText.Text, InfoBarSeverity.Success);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
MeshStatusText.Text = "Hub недоступен";
|
||||||
|
ShowInfo("Не удалось получить Mesh-состояние", CompactError(exception), InfoBarSeverity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void RefreshMeshButton_Click(object sender, RoutedEventArgs e)
|
||||||
|
=> await RefreshMeshAsync();
|
||||||
|
|
||||||
|
private async void ConnectLinkButton_Click(object sender, RoutedEventArgs e)
|
||||||
|
=> await ChangeLinkAsync(enable: true);
|
||||||
|
|
||||||
|
private async void DisconnectLinkButton_Click(object sender, RoutedEventArgs e)
|
||||||
|
=> await ChangeLinkAsync(enable: false);
|
||||||
|
|
||||||
|
private async Task ChangeLinkAsync(bool enable)
|
||||||
|
{
|
||||||
|
var hub = FindHub();
|
||||||
|
if (hub is null)
|
||||||
|
{
|
||||||
|
ShowInfo("Mesh Hub не выбран", "Сначала добавьте главный сервер с отметкой Mesh Hub.", InfoBarSeverity.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (SourceNodeBox.SelectedItem is not MeshNodeViewModel source
|
||||||
|
|| TargetNodeBox.SelectedItem is not MeshNodeViewModel target)
|
||||||
|
{
|
||||||
|
ShowInfo("Выберите серверы", "Укажите источник и сервер назначения.", InfoBarSeverity.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (source.Name == target.Name)
|
||||||
|
{
|
||||||
|
ShowInfo("Некорректная связь", "Источник и назначение должны отличаться.", InfoBarSeverity.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var action = enable ? "connect" : "disconnect";
|
||||||
|
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15));
|
||||||
|
await _ssh.RunRestrictedCommandAsync(
|
||||||
|
hub.Profile,
|
||||||
|
$"mesh {action} {source.Name} {target.Name}",
|
||||||
|
timeout.Token);
|
||||||
|
await RefreshMeshAsync(showSuccess: false);
|
||||||
|
ShowInfo(
|
||||||
|
enable ? "Связь включена" : "Связь отключена",
|
||||||
|
$"{source.Name} → {target.Name}",
|
||||||
|
enable ? InfoBarSeverity.Success : InfoBarSeverity.Warning);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
ShowInfo("Не удалось изменить связь", CompactError(exception), InfoBarSeverity.Error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void MainNavigation_SelectionChanged(NavigationView sender, NavigationViewSelectionChangedEventArgs args)
|
private void MainNavigation_SelectionChanged(NavigationView sender, NavigationViewSelectionChangedEventArgs args)
|
||||||
|
|
|
||||||
31
src/ServerMonitorManager.Desktop/MeshModels.cs
Normal file
31
src/ServerMonitorManager.Desktop/MeshModels.cs
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
namespace ServerMonitorManager_Desktop;
|
||||||
|
|
||||||
|
public sealed class MeshNodeViewModel
|
||||||
|
{
|
||||||
|
public MeshNodeViewModel(string name, string address, string state, int handshakeAgeSeconds)
|
||||||
|
{
|
||||||
|
Name = name;
|
||||||
|
Address = address;
|
||||||
|
State = state;
|
||||||
|
HandshakeAgeSeconds = handshakeAgeSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Name { get; set; }
|
||||||
|
public string Address { get; set; }
|
||||||
|
public string State { get; set; }
|
||||||
|
public int HandshakeAgeSeconds { get; set; }
|
||||||
|
public string Label => $"{Name} · {Address} · {(State == "online" ? "онлайн" : "не в сети")}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class MeshLinkViewModel
|
||||||
|
{
|
||||||
|
public MeshLinkViewModel(string source, string target)
|
||||||
|
{
|
||||||
|
Source = source;
|
||||||
|
Target = target;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Source { get; set; }
|
||||||
|
public string Target { get; set; }
|
||||||
|
public string Label => $"{Source} → {Target}";
|
||||||
|
}
|
||||||
|
|
@ -8,7 +8,8 @@ public sealed record ServerProfileData(
|
||||||
string Name,
|
string Name,
|
||||||
string Host,
|
string Host,
|
||||||
int Port,
|
int Port,
|
||||||
string User);
|
string User,
|
||||||
|
bool IsHub = false);
|
||||||
|
|
||||||
public sealed class ServerViewModel : INotifyPropertyChanged
|
public sealed class ServerViewModel : INotifyPropertyChanged
|
||||||
{
|
{
|
||||||
|
|
@ -25,6 +26,7 @@ public sealed class ServerViewModel : INotifyPropertyChanged
|
||||||
public ServerProfileData Profile { get; }
|
public ServerProfileData Profile { get; }
|
||||||
public string Name => Profile.Name;
|
public string Name => Profile.Name;
|
||||||
public string Endpoint => $"{Profile.User}@{Profile.Host}:{Profile.Port}";
|
public string Endpoint => $"{Profile.User}@{Profile.Host}:{Profile.Port}";
|
||||||
|
public bool IsHub => Profile.IsHub;
|
||||||
public string Status { get => _status; set => Set(ref _status, value); }
|
public string Status { get => _status; set => Set(ref _status, value); }
|
||||||
public string CpuText { get => _cpuText; set => Set(ref _cpuText, value); }
|
public string CpuText { get => _cpuText; set => Set(ref _cpuText, value); }
|
||||||
public double CpuPercent { get => _cpuPercent; set => Set(ref _cpuPercent, value); }
|
public double CpuPercent { get => _cpuPercent; set => Set(ref _cpuPercent, value); }
|
||||||
|
|
|
||||||
|
|
@ -44,31 +44,8 @@ public sealed partial class SshMonitorService
|
||||||
ServerProfileData profile,
|
ServerProfileData profile,
|
||||||
CancellationToken cancellationToken = default)
|
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 stopwatch = Stopwatch.StartNew();
|
||||||
var output = await RunProcessAsync(
|
var output = await RunRestrictedCommandAsync(profile, "metrics", cancellationToken);
|
||||||
ResolveOpenSshTool("ssh.exe"),
|
|
||||||
arguments,
|
|
||||||
cancellationToken);
|
|
||||||
stopwatch.Stop();
|
stopwatch.Stop();
|
||||||
|
|
||||||
var values = output
|
var values = output
|
||||||
|
|
@ -96,6 +73,40 @@ public sealed partial class SshMonitorService
|
||||||
stopwatch.Elapsed);
|
stopwatch.Elapsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<string> RunRestrictedCommandAsync(
|
||||||
|
ServerProfileData profile,
|
||||||
|
string command,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ValidateProfile(profile);
|
||||||
|
if (!SafeRestrictedCommandRegex().IsMatch(command))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Некорректная команда управления Mesh.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await EnsureKeyPairAsync(cancellationToken);
|
||||||
|
var localFolder = ApplicationData.Current.LocalFolder.Path;
|
||||||
|
var privateKeyPath = 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,
|
||||||
|
command
|
||||||
|
};
|
||||||
|
return await RunProcessAsync(
|
||||||
|
ResolveOpenSshTool("ssh.exe"),
|
||||||
|
arguments,
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
private static void ValidateProfile(ServerProfileData profile)
|
private static void ValidateProfile(ServerProfileData profile)
|
||||||
{
|
{
|
||||||
if (!SafeHostRegex().IsMatch(profile.Host))
|
if (!SafeHostRegex().IsMatch(profile.Host))
|
||||||
|
|
@ -189,4 +200,7 @@ public sealed partial class SshMonitorService
|
||||||
|
|
||||||
[GeneratedRegex("^[a-z_][a-z0-9_-]{0,31}$", RegexOptions.CultureInvariant)]
|
[GeneratedRegex("^[a-z_][a-z0-9_-]{0,31}$", RegexOptions.CultureInvariant)]
|
||||||
private static partial Regex SafeUserRegex();
|
private static partial Regex SafeUserRegex();
|
||||||
|
|
||||||
|
[GeneratedRegex("^(metrics|mesh (nodes|links|status|connect [a-z0-9][a-z0-9-]{0,31} [a-z0-9][a-z0-9-]{0,31}|disconnect [a-z0-9][a-z0-9-]{0,31} [a-z0-9][a-z0-9-]{0,31}))$", RegexOptions.CultureInvariant)]
|
||||||
|
private static partial Regex SafeRestrictedCommandRegex();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue