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
5 changed files with 114 additions and 14 deletions
Showing only changes of commit 231ba69754 - Show all commits

View file

@ -48,8 +48,8 @@
- [x] направленные пары source → destination;
- [x] ручное добавление и удаление nftables ACL из Windows-клиента;
- [ ] политики по CIDR, TCP/UDP и порту;
- [ ] TTL и автоматическое истечение;
- [x] политики по целевому `/32`, TCP/UDP и порту;
- [x] TTL и автоматическое истечение;
- [ ] состояния Connecting, Active, Disconnecting, Partial, Disabled и Failed;
- [ ] версия политики и подтверждение применения;
- [ ] обязательное отключение после reconnect;

View file

@ -244,6 +244,36 @@
Header="Целевой сервер"
ItemsSource="{x:Bind MeshNodes}" />
<Grid ColumnSpacing="8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<ComboBox
x:Name="LinkProtocolBox"
Header="Протокол"
SelectedIndex="0">
<ComboBoxItem Content="TCP" Tag="tcp" />
<ComboBoxItem Content="UDP" Tag="udp" />
</ComboBox>
<NumberBox
x:Name="LinkPortBox"
Grid.Column="1"
Header="Порт"
Maximum="65535"
Minimum="1"
SpinButtonPlacementMode="Compact"
Value="22" />
</Grid>
<NumberBox
x:Name="LinkTtlBox"
Header="TTL, минут (0 — до ручного отключения)"
Maximum="525600"
Minimum="0"
SpinButtonPlacementMode="Compact"
Value="120" />
<Grid ColumnSpacing="8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
@ -265,9 +295,10 @@
<TextBlock FontWeight="SemiBold" Text="Разрешённые направления" />
<ListView
x:Name="MeshLinksList"
MaxHeight="220"
ItemsSource="{x:Bind MeshLinks}"
SelectionMode="None">
SelectionMode="Single">
<ListView.ItemTemplate>
<DataTemplate x:DataType="local:MeshLinkViewModel">
<TextBlock Margin="4,8" Text="{x:Bind Label}" />

View file

@ -306,9 +306,17 @@ public sealed partial class MainPage : Page
continue;
}
var fields = line[5..].Split('|');
if (fields.Length >= 2)
if (fields.Length >= 7
&& int.TryParse(fields[4], CultureInfo.InvariantCulture, out var port)
&& long.TryParse(fields[5], CultureInfo.InvariantCulture, out var expiresUnix))
{
MeshLinks.Add(new MeshLinkViewModel(fields[0], fields[1]));
MeshLinks.Add(new MeshLinkViewModel(
fields[0],
fields[1],
fields[2],
fields[3],
port,
expiresUnix));
}
}
@ -343,12 +351,53 @@ public sealed partial class MainPage : Page
ShowInfo("Mesh Hub не выбран", "Сначала добавьте главный сервер с отметкой Mesh Hub.", InfoBarSeverity.Warning);
return;
}
if (SourceNodeBox.SelectedItem is not MeshNodeViewModel source
|| TargetNodeBox.SelectedItem is not MeshNodeViewModel target)
MeshNodeViewModel? source = null;
MeshNodeViewModel? target = null;
string protocol;
int port;
int ttlMinutes;
if (enable)
{
ShowInfo("Выберите серверы", "Укажите источник и сервер назначения.", InfoBarSeverity.Warning);
return;
if (SourceNodeBox.SelectedItem is not MeshNodeViewModel selectedSource
|| TargetNodeBox.SelectedItem is not MeshNodeViewModel selectedTarget)
{
ShowInfo("Выберите серверы", "Укажите источник и сервер назначения.", InfoBarSeverity.Warning);
return;
}
source = selectedSource;
target = selectedTarget;
protocol = (LinkProtocolBox.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "tcp";
if (double.IsNaN(LinkPortBox.Value)
|| double.IsNaN(LinkTtlBox.Value)
|| LinkPortBox.Value is < 1 or > 65535
|| LinkTtlBox.Value is < 0 or > 525600)
{
ShowInfo("Некорректная политика", "Проверьте порт и TTL.", InfoBarSeverity.Warning);
return;
}
port = checked((int)LinkPortBox.Value);
ttlMinutes = checked((int)LinkTtlBox.Value);
}
else
{
if (MeshLinksList.SelectedItem is not MeshLinkViewModel selectedLink)
{
ShowInfo("Выберите связь", "Для отключения выберите правило в списке.", InfoBarSeverity.Warning);
return;
}
source = MeshNodes.FirstOrDefault(node => node.Name == selectedLink.Source);
target = MeshNodes.FirstOrDefault(node => node.Name == selectedLink.Target);
if (source is null || target is null)
{
ShowInfo("Узел не найден", "Обновите список Mesh и повторите попытку.", InfoBarSeverity.Warning);
return;
}
protocol = selectedLink.Protocol;
port = selectedLink.Port;
ttlMinutes = 0;
}
if (source.Name == target.Name)
{
ShowInfo("Некорректная связь", "Источник и назначение должны отличаться.", InfoBarSeverity.Warning);
@ -358,15 +407,18 @@ public sealed partial class MainPage : Page
try
{
var action = enable ? "connect" : "disconnect";
var policyArguments = enable
? $"{protocol} {port} {ttlMinutes}"
: $"{protocol} {port}";
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15));
await _ssh.RunRestrictedCommandAsync(
hub.Profile,
$"mesh {action} {source.Name} {target.Name}",
$"mesh {action} {source.Name} {target.Name} {policyArguments}",
timeout.Token);
await RefreshMeshAsync(showSuccess: false);
ShowInfo(
enable ? "Связь включена" : "Связь отключена",
$"{source.Name} → {target.Name}",
$"{source.Name} → {target.Name} · {protocol.ToUpperInvariant()}/{port}",
enable ? InfoBarSeverity.Success : InfoBarSeverity.Warning);
}
catch (Exception exception)

View file

@ -19,13 +19,30 @@ public sealed class MeshNodeViewModel
public sealed class MeshLinkViewModel
{
public MeshLinkViewModel(string source, string target)
public MeshLinkViewModel(
string source,
string target,
string cidr,
string protocol,
int port,
long expiresUnix)
{
Source = source;
Target = target;
Cidr = cidr;
Protocol = protocol;
Port = port;
ExpiresUnix = expiresUnix;
}
public string Source { get; set; }
public string Target { get; set; }
public string Label => $"{Source} → {Target}";
public string Cidr { get; set; }
public string Protocol { get; set; }
public int Port { get; set; }
public long ExpiresUnix { get; set; }
public string ExpirationText => ExpiresUnix == 0
? "вручную"
: $"до {DateTimeOffset.FromUnixTimeSeconds(ExpiresUnix).ToLocalTime():dd.MM HH:mm}";
public string Label => $"{Source} → {Target} · {Protocol.ToUpperInvariant()}/{Port} · {Cidr} · {ExpirationText}";
}

View file

@ -201,6 +201,6 @@ 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)]
[GeneratedRegex("^(metrics|mesh (nodes|links|status|connect [a-z0-9][a-z0-9-]{0,31} [a-z0-9][a-z0-9-]{0,31} (tcp|udp) [0-9]{1,5} [0-9]{1,6}|disconnect [a-z0-9][a-z0-9-]{0,31} [a-z0-9][a-z0-9-]{0,31} (tcp|udp) [0-9]{1,5}))$", RegexOptions.CultureInvariant)]
private static partial Regex SafeRestrictedCommandRegex();
}