diff --git a/README.md b/README.md
index 10a12e9..aea76f1 100644
--- a/README.md
+++ b/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, не затрагивая другие связи узла.
## Лицензия и секреты
diff --git a/deploy/ochenstarik-server-monitor-manager.sh b/deploy/ochenstarik-server-monitor-manager.sh
deleted file mode 100644
index eea5eac..0000000
--- a/deploy/ochenstarik-server-monitor-manager.sh
+++ /dev/null
@@ -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 "$@"
diff --git a/src/ServerMonitorManager.Desktop/MainPage.xaml b/src/ServerMonitorManager.Desktop/MainPage.xaml
index 55b3922..4ea43ad 100644
--- a/src/ServerMonitorManager.Desktop/MainPage.xaml
+++ b/src/ServerMonitorManager.Desktop/MainPage.xaml
@@ -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 @@
-
-
+
+
@@ -214,60 +215,71 @@
-
+
+ x:Name="MeshStatusText"
+ Foreground="{ThemeResource TextFillColorSecondaryBrush}"
+ Text="Добавьте главный Mesh Hub" />
-
+ AutomationProperties.Name="Обновить список серверов и связей"
+ Click="RefreshMeshButton_Click">
+
+
-
+
+
+
+
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
-
-
-
diff --git a/src/ServerMonitorManager.Desktop/MainPage.xaml.cs b/src/ServerMonitorManager.Desktop/MainPage.xaml.cs
index 198fed7..192dc6b 100644
--- a/src/ServerMonitorManager.Desktop/MainPage.xaml.cs
+++ b/src/ServerMonitorManager.Desktop/MainPage.xaml.cs
@@ -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 Servers { get; } = [];
+ public ObservableCollection MeshNodes { get; } = [];
+ public ObservableCollection 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)
diff --git a/src/ServerMonitorManager.Desktop/MeshModels.cs b/src/ServerMonitorManager.Desktop/MeshModels.cs
new file mode 100644
index 0000000..14148bc
--- /dev/null
+++ b/src/ServerMonitorManager.Desktop/MeshModels.cs
@@ -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}";
+}
diff --git a/src/ServerMonitorManager.Desktop/ServerViewModel.cs b/src/ServerMonitorManager.Desktop/ServerViewModel.cs
index d271c4f..194c6b6 100644
--- a/src/ServerMonitorManager.Desktop/ServerViewModel.cs
+++ b/src/ServerMonitorManager.Desktop/ServerViewModel.cs
@@ -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); }
diff --git a/src/ServerMonitorManager.Desktop/SshMonitorService.cs b/src/ServerMonitorManager.Desktop/SshMonitorService.cs
index ac8e42a..46ad15f 100644
--- a/src/ServerMonitorManager.Desktop/SshMonitorService.cs
+++ b/src/ServerMonitorManager.Desktop/SshMonitorService.cs
@@ -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 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();
}