Add Windows SSH monitoring MVP #1

Merged
ochenstarik-ui merged 23 commits from agent/windows-ssh-monitoring into main 2026-07-16 15:25:19 +00:00
7 changed files with 269 additions and 291 deletions
Showing only changes of commit 1f0eaedaf1 - Show all commits

View file

@ -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, не затрагивая другие связи узла.
## Лицензия и секреты ## Лицензия и секреты

View file

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

View file

@ -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="&#xE72C;" />
</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>

View file

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

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

View file

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

View file

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