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
|
||||
Windows client
|
||||
|
|
||||
| HTTPS / WebSocket, mTLS
|
||||
v
|
||||
Control node (может работать на одном из ваших серверов)
|
||||
^
|
||||
| только исходящие соединения агентов
|
||||
|
|
||||
Linux agents ---- управляемые WireGuard links ---- Linux agents
|
||||
Windows client -- ограниченный SSH --> главный WireGuard Hub с белым IP
|
||||
^
|
||||
| исходящие WireGuard-соединения
|
||||
|
|
||||
Hermes / Home / другие серверы
|
||||
```
|
||||
|
||||
Мониторинг и межсерверные связи разделены. Регистрация агента не создаёт сетевого туннеля и не выдаёт доступ к SSH. Каждая связь создаётся отдельно, имеет список разрешённых адресов и портов, может иметь срок действия и всегда поддерживает ручной `Disconnect`.
|
||||
Hub объединяет узлы в приватной подсети, но по умолчанию запрещает транзит между ними. Каждая связь `источник → цель` включается и отключается отдельно. Поэтому можно оставить `Hermes → Home`, отключив только `Hermes → Server2`. Обратное направление не появляется автоматически.
|
||||
|
||||
Подробности: [архитектура](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.
|
||||
|
||||
Установочный скрипт 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-ключ` → `Копировать`.
|
||||
2. На каждом Ubuntu/Debian-сервере запустите одну команду:
|
||||
2. На сервере с белым IP скачайте установщик и выберите режим Hub:
|
||||
|
||||
```bash
|
||||
curl -fsSL \
|
||||
https://raw.githubusercontent.com/ochenstarik-ui/lightweight-server/agent/server-monitor-installer/ochenstarik-server-monitor-manager.sh \
|
||||
| sudo bash -s -- install
|
||||
curl -fLO 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 ./ochenstarik-server-monitor-manager.sh hub
|
||||
```
|
||||
|
||||
3. Вставьте скопированный публичный ключ по запросу скрипта.
|
||||
4. В приложении нажмите `Добавить сервер`, укажите IP/домен, текущий SSH-порт и пользователя `ochenstarik-monitor`.
|
||||
5. Повторите для остальных серверов и нажмите `Обновить`.
|
||||
3. Откройте выбранный UDP-порт WireGuard (по умолчанию `51820`) и создайте коды узлов:
|
||||
|
||||
```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` приложения.
|
||||
|
||||
Для повторяемой установки без ручного ввода публичный ключ можно передать через окружение. Это публичная часть ключа, не секрет:
|
||||
|
||||
```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` и просмотрите его содержимое.
|
||||
|
||||
Код Node содержит приватный ключ WireGuard узла. Считайте его секретом, используйте только на предназначенном сервере и не сохраняйте в Git или истории сообщений.
|
||||
|
||||
## Принципы безопасности
|
||||
|
||||
- никаких общих root-паролей и приватных SSH-ключей в control node;
|
||||
- на первом SSH-MVP — отдельный ключ мониторинга и pinned `known_hosts`; enrollment tokens появятся вместе с постоянным агентом;
|
||||
- никаких общих root-паролей и приватных SSH-ключей на Hub;
|
||||
- отдельный ключ мониторинга и pinned `known_hosts`; код регистрации Node передаётся один раз вручную;
|
||||
- отдельная идентичность для пользователя, устройства, агента и автоматизации;
|
||||
- опасные действия требуют повторного подтверждения и попадают в неизменяемый аудит;
|
||||
- доступ 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: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"
|
||||
mc:Ignorable="d">
|
||||
|
||||
|
|
@ -122,8 +123,8 @@
|
|||
<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" />
|
||||
<TextBlock x:Name="ActiveLinksValueText" FontSize="24" FontWeight="SemiBold" Text="0" />
|
||||
<TextBlock Foreground="{ThemeResource TextFillColorSecondaryBrush}" Text="разрешённые направления" />
|
||||
</StackPanel>
|
||||
<Rectangle Grid.Column="5" Fill="{ThemeResource DividerStrokeColorDefaultBrush}" />
|
||||
<StackPanel Grid.Column="6" Spacing="4">
|
||||
|
|
@ -214,60 +215,71 @@
|
|||
<StackPanel Spacing="18">
|
||||
<Grid>
|
||||
<StackPanel Spacing="3">
|
||||
<TextBlock Style="{StaticResource SubtitleTextBlockStyle}" Text="Hermes → Home" />
|
||||
<TextBlock Style="{StaticResource SubtitleTextBlockStyle}" Text="Связи серверов" />
|
||||
<TextBlock
|
||||
x:Name="LinkStatusText"
|
||||
Foreground="{ThemeResource SystemFillColorSuccessBrush}"
|
||||
Text="Защищённая связь активна" />
|
||||
x:Name="MeshStatusText"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Text="Добавьте главный Mesh Hub" />
|
||||
</StackPanel>
|
||||
<ToggleSwitch
|
||||
x:Name="ManualLinkToggle"
|
||||
<Button
|
||||
HorizontalAlignment="Right"
|
||||
AutomationProperties.Name="Подключение Hermes к Home Lab"
|
||||
IsOn="True" />
|
||||
AutomationProperties.Name="Обновить список серверов и связей"
|
||||
Click="RefreshMeshButton_Click">
|
||||
<FontIcon Glyph="" />
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<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>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<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="Только вручную" />
|
||||
<Button
|
||||
HorizontalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Center"
|
||||
Click="ConnectLinkButton_Click"
|
||||
Content="Разрешить"
|
||||
Style="{StaticResource AccentButtonStyle}" />
|
||||
<Button
|
||||
Grid.Column="1"
|
||||
HorizontalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Center"
|
||||
Click="DisconnectLinkButton_Click"
|
||||
Content="Отключить" />
|
||||
</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
|
||||
IsClosable="False"
|
||||
IsOpen="True"
|
||||
Message="Отключение удалит peer и маршрут на обоих серверах. SSH-ключ Hermes останется отдельным уровнем доступа."
|
||||
Message="Связь направленная: Hermes → Home разрешает Hermes обращаться к Home. Обратное направление включается отдельно."
|
||||
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>
|
||||
|
||||
|
|
|
|||
|
|
@ -16,12 +16,13 @@ public sealed partial class MainPage : Page
|
|||
public MainPage()
|
||||
{
|
||||
InitializeComponent();
|
||||
ManualLinkToggle.Toggled += ManualLinkToggle_Toggled;
|
||||
Loaded += MainPage_Loaded;
|
||||
MainNavigation.SelectedItem = MainNavigation.MenuItems[0];
|
||||
}
|
||||
|
||||
public ObservableCollection<ServerViewModel> Servers { get; } = [];
|
||||
public ObservableCollection<MeshNodeViewModel> MeshNodes { get; } = [];
|
||||
public ObservableCollection<MeshLinkViewModel> MeshLinks { get; } = [];
|
||||
|
||||
private async void MainPage_Loaded(object sender, RoutedEventArgs e)
|
||||
{
|
||||
|
|
@ -116,12 +117,17 @@ public sealed partial class MainPage : Page
|
|||
SpinButtonPlacementMode = NumberBoxSpinButtonPlacementMode.Compact
|
||||
};
|
||||
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 content = new StackPanel
|
||||
{
|
||||
Spacing = 12,
|
||||
MinWidth = 420,
|
||||
Children = { nameBox, hostBox, portBox, userBox, validationText }
|
||||
Children = { nameBox, hostBox, portBox, userBox, hubBox, validationText }
|
||||
};
|
||||
var dialog = new ContentDialog
|
||||
{
|
||||
|
|
@ -154,7 +160,8 @@ public sealed partial class MainPage : Page
|
|||
nameBox.Text.Trim(),
|
||||
hostBox.Text.Trim(),
|
||||
checked((int)portBox.Value),
|
||||
userBox.Text.Trim());
|
||||
userBox.Text.Trim(),
|
||||
hubBox.IsChecked == true);
|
||||
var server = new ServerViewModel(profile);
|
||||
Servers.Add(server);
|
||||
await SaveProfilesAsync();
|
||||
|
|
@ -184,6 +191,10 @@ public sealed partial class MainPage : Page
|
|||
WarningValueText.Text = warnings.ToString(CultureInfo.InvariantCulture);
|
||||
WarningDetailText.Text = warnings == 0 ? "Нет предупреждений" : "Проверьте SSH и ключ";
|
||||
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)
|
||||
|
|
@ -256,26 +267,112 @@ public sealed partial class MainPage : Page
|
|||
LinkActionInfo.IsOpen = true;
|
||||
}
|
||||
|
||||
private void ManualLinkToggle_Toggled(object sender, RoutedEventArgs e)
|
||||
=> ApplyLinkState(ManualLinkToggle.IsOn);
|
||||
private ServerViewModel? FindHub()
|
||||
=> Servers.FirstOrDefault(server => server.IsHub);
|
||||
|
||||
private void DisconnectButton_Click(object sender, RoutedEventArgs e)
|
||||
=> ManualLinkToggle.IsOn = false;
|
||||
|
||||
private void ApplyLinkState(bool isActive)
|
||||
private async Task RefreshMeshAsync(bool showSuccess = true)
|
||||
{
|
||||
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);
|
||||
var hub = FindHub();
|
||||
if (hub is null)
|
||||
{
|
||||
ShowInfo("Mesh Hub не выбран", "Добавьте главный сервер или отметьте его как Mesh Hub.", InfoBarSeverity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15));
|
||||
var nodesOutput = await _ssh.RunRestrictedCommandAsync(hub.Profile, "mesh nodes", timeout.Token);
|
||||
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)
|
||||
|
|
|
|||
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 Host,
|
||||
int Port,
|
||||
string User);
|
||||
string User,
|
||||
bool IsHub = false);
|
||||
|
||||
public sealed class ServerViewModel : INotifyPropertyChanged
|
||||
{
|
||||
|
|
@ -25,6 +26,7 @@ public sealed class ServerViewModel : INotifyPropertyChanged
|
|||
public ServerProfileData Profile { get; }
|
||||
public string Name => Profile.Name;
|
||||
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 CpuText { get => _cpuText; set => Set(ref _cpuText, value); }
|
||||
public double CpuPercent { get => _cpuPercent; set => Set(ref _cpuPercent, value); }
|
||||
|
|
|
|||
|
|
@ -44,31 +44,8 @@ public sealed partial class SshMonitorService
|
|||
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);
|
||||
var output = await RunRestrictedCommandAsync(profile, "metrics", cancellationToken);
|
||||
stopwatch.Stop();
|
||||
|
||||
var values = output
|
||||
|
|
@ -96,6 +73,40 @@ public sealed partial class SshMonitorService
|
|||
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)
|
||||
{
|
||||
if (!SafeHostRegex().IsMatch(profile.Host))
|
||||
|
|
@ -189,4 +200,7 @@ public sealed partial class SshMonitorService
|
|||
|
||||
[GeneratedRegex("^[a-z_][a-z0-9_-]{0,31}$", RegexOptions.CultureInvariant)]
|
||||
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